CoursesKustomizeWhat Kustomize is: the overlay model

What Kustomize is: the overlay model

Template-free customization.

Intermediate12 min · lesson 1 of 12

You keep one master recipe card for bread: flour, water, salt, and the method. When you bake for a party, you don't rewrite the card. You clip a sticky note to it that reads 'double it, make two loaves.' The card stays clean. The note holds only what changed. Anyone can read the card on its own, and anyone can read the note on its own. Kustomize customizes Kubernetes configuration the same way.

Kubernetes (the system that runs your containers across a fleet of machines) is driven by manifests. A manifest is a YAML file (YAML is a plain-text format for writing configuration) that describes one thing you want in the cluster. That thing might be a Deployment (an object that keeps a set of identical Pods running, where a Pod is the smallest unit Kubernetes schedules: one or more containers that live and die together), or a Service (a stable network name that sits in front of those Pods so other things can reach them). Kustomize is a tool that takes a folder of those plain manifests and produces a customized version of them, without ever turning them into templates.

Helm, the most common Kubernetes packaging tool, customizes YAML by punching holes in it. You write placeholders like {{ .Values.replicas }} and hand it a values.yaml file to fill them in. That turns a manifest into a small program that prints YAML. Kustomize refuses to do that. Your files stay real, valid manifests that kubectl (the command-line tool that talks to your cluster) could apply exactly as they sit on disk. The customization lives in a separate file, kustomization.yaml, that says what to assemble and what to change.

The base is the recipe everyone shares

A base is the master recipe card: the manifests every environment agrees on, with nothing environment-specific baked in. Here is a small app laid out the usual way, a base folder plus one folder per environment.

terminal
tree app
output
app
├── base
│ ├── deployment.yaml
│ ├── kustomization.yaml
│ └── service.yaml
└── overlays
├── dev
│ └── kustomization.yaml
└── prod
├── kustomization.yaml
└── replicas.yaml
4 directories, 6 files

The base has its own kustomization.yaml. It does one job here: name the manifests that belong to this set.

app/base/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- deployment.yaml
- service.yaml

The two files it lists are ordinary Kubernetes YAML. Here is the Deployment. The Service next to it is a plain Service, which defaults to a ClusterIP (an internal-only address inside the cluster).

app/base/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
spec:
replicas: 1
selector:
matchLabels:
app: web
template:
metadata:
labels:
app: web
spec:
containers:
- name: web
image: ghcr.io/acme/web:1.4.0
ports:
- containerPort: 8080

Notice replicas: 1 and the image tag 1.4.0. Nothing in this file knows the word 'prod.' You could run kubectl apply -f on it today and it would work. That is the whole point of a base: it is complete and valid on its own, so every tool and every reader can make sense of it without any extra step.

An overlay is the sticky note on top

An overlay is the sticky note. It is its own kustomization.yaml that points back at the base and lists only the differences for one environment. Here is the prod overlay.

app/overlays/prod/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: prod
namePrefix: prod-
resources:
- ../../base
images:
- name: ghcr.io/acme/web
newTag: 2.1.0
patches:
- path: replicas.yaml

Read it top to bottom. resources points at the base folder, so everything in the base comes along. namespace and namePrefix are transformers (rules that rewrite one field across every object the build produces): they drop every object into the prod namespace (a named partition inside the cluster) and rename web to prod-web. images swaps the tag on that one image to 2.1.0 without editing the file that declares it. patches points at a small file for a change a transformer can't express cleanly.

app/overlays/prod/replicas.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
spec:
replicas: 4

That patch file is a strategic merge patch: a partial manifest that names the object by kind and name, then lists only the fields to change. It says 'the Deployment called web, set replicas to 4,' and stays silent about everything else. Notice it targets the original name web, not prod-web, because patches are applied before namePrefix renames anything. The base still reads replicas: 1; the difference lives entirely in the overlay, where anyone reviewing prod can see it in one place.

Build it, then look before you ship

Nothing has touched the cluster yet. To see the assembled result, ask kubectl to render the overlay. kubectl kustomize prints the final YAML to your terminal and stops. (Kustomize also ships as a standalone binary, where the same command is kustomize build; the built-in kubectl version needs nothing installed.)

terminal
kubectl kustomize app/overlays/prod
output
apiVersion: v1
kind: Service
metadata:
name: prod-web
namespace: prod
spec:
ports:
- port: 80
targetPort: 8080
selector:
app: web
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: prod-web
namespace: prod
spec:
replicas: 4
selector:
matchLabels:
app: web
template:
metadata:
labels:
app: web
spec:
containers:
- name: web
image: ghcr.io/acme/web:2.1.0
ports:
- containerPort: 8080

Read what came out. The Service and the Deployment are both there, both renamed to prod-web, both in the prod namespace. The image is 2.1.0. The replica count is 4. This is exactly what would land in the cluster, character for character. There is no hidden templating step left to run, which means the rendered text is not a preview of the real thing, it is the real thing.

Two more commands close the loop. kubectl diff -k renders the overlay and shows how it differs from what is running right now, before you change anything. kubectl apply -k builds and applies in one step.

terminal
kubectl diff -k app/overlays/prod
output
diff -u -N /tmp/LIVE-1837/apps.v1.Deployment.prod.prod-web /tmp/MERGED-2044/apps.v1.Deployment.prod.prod-web
--- /tmp/LIVE-1837/apps.v1.Deployment.prod.prod-web 2026-07-20 10:15:33.482910245 +0000
+++ /tmp/MERGED-2044/apps.v1.Deployment.prod.prod-web 2026-07-20 10:15:33.500114872 +0000
@@ -16,7 +16,7 @@
name: prod-web
namespace: prod
spec:
- replicas: 2
+ replicas: 4
selector:
matchLabels:
app: web
@@ -30,5 +30,5 @@
containers:
- name: web
- image: ghcr.io/acme/web:2.0.0
+ image: ghcr.io/acme/web:2.1.0
ports:
- containerPort: 8080
terminal
kubectl apply -k app/overlays/prod
output
service/prod-web unchanged
deployment.apps/prod-web configured

Template-free, and why that helps you

Because a manifest never stops being a manifest, every tool that understands Kubernetes YAML keeps working on your files. Your editor autocompletes fields. A schema checker (a tool that flags an invalid field name or a wrong type) reads them directly. A security scanner such as kube-linter or Checkov can inspect both the base and the rendered output. With Helm's placeholders, those tools see a broken half-YAML file until Helm fills the holes at build time. There is no whitespace-sensitive template language to fight, and no second mental model to hold: what you read is what deploys.

The trade-off is reach. Helm's templates and its packaging (a chart you can version and hand to strangers) do more for distributing an app you don't control, where the person installing it needs dozens of knobs. Kustomize aims lower and hits it cleanly: take manifests you already own and bend them per environment. A common split is Kustomize for your own overlays and Helm for third-party apps you install, and later lessons cover running the two together.

Review the assembled output, not the overlay

Here is the operational catch. A prod overlay can be four lines and still change something that matters, because most of what deploys comes from the base and the patches, not from the short file you are reading. A patch can strip a securityContext (the block that decides whether a container runs as root or can gain extra privileges). A base or a transformer can widen RBAC (role-based access control, the rules for who can do what in the cluster). An image line can point somewhere new. None of that is visible in a tidy diff of the overlay file. The assembled output is the only honest source of truth, so review that.

The habit is a one-liner. Render the exact overlay you plan to apply, then grep the result for the fields you care about before it goes anywhere.

terminal
kubectl kustomize app/overlays/prod | grep -nE 'privileged|allowPrivilegeEscalation|hostPath|runAsUser|NET_ADMIN'

Nothing matched, so nothing in this build asks for privilege it shouldn't have. If a teammate's patch, or a base you pulled in, had added privileged: true or a hostPath mount (a container mounting a path from the node's own filesystem, a classic route off the container and onto the host), this is where it would surface, in the rendered object, while the overlay file still looked innocent. Reading the sticky note is not enough. You verify the finished loaf.

The overlay is not the whole story
A short, clean-looking overlay can pull large changes from its base, its patches, and any remote base it references (a base can be a Git URL, so the build pulls in manifests and transformers from a source you do not control, and that source can change between one build and the next). Reviewing only the overlay diff in a pull request misses all of it. Always run kubectl kustomize (or kustomize build) on the exact overlay you will apply, and review that rendered YAML, because the assembled result is what reaches the cluster, not the file you skimmed.
How a manifest reaches the cluster with Kustomize
1Base manifests
plain, valid YAML shared by every environment
2Overlay
patches and transformers: only what differs for this env
3kubectl kustomize
assembles base + overlay into the final YAML
4Rendered manifests
review THIS: the exact object that will deploy
5kubectl apply
the reviewed YAML lands in the cluster
Quick check
01A pull request touches only app/overlays/prod/kustomization.yaml and the diff is three lines. What is the right move before you trust what will reach the cluster?
Incorrect — The base and the overlay are only part of the story. Patches rewrite fields neither file spells out, and a base can be a Git URL whose contents change between builds.
Correct — There is no templating step left after that render, so the printed text is not a preview of what deploys, it is what deploys. Grep it for the fields you care about.
Incorrect — A patch can strip a securityContext, and a base or transformer can widen RBAC. Those changes reach the cluster while the overlay file still looks innocent.
Incorrect — Kustomize does no templating and the assembly happens on your machine. kubectl kustomize builds the manifests and stops without contacting the cluster at all.
02Your CI job runs a schema checker and Checkov against the files in app/base. Why does Kustomize's refusal to templatize those files make that job simpler than the Helm equivalent?
Incorrect — Kubernetes never sees kustomization.yaml. Something has to assemble the manifests first, which is exactly what kubectl kustomize or kustomize build does for you.
Incorrect — Kustomize has no placeholders of any kind, and this describes neither tool. The sources sit on disk as ordinary files whether you use Kustomize or Helm.
Correct — Editors, schema checkers, and scanners all read the file as it sits, and you can point them at the base and at the rendered output alike.
Incorrect — That packaging reach is what Helm is good at. Kustomize aims lower on purpose: take manifests you already own and bend them per environment.
03In app/overlays/prod/replicas.yaml the strategic merge patch names the Deployment web, yet the rendered object comes out as prod-web. Suppose you edited that patch to name prod-web instead. What happens?
Correct — Ordering decides this. The patch runs against the base name first, then the prefix is stamped on, and a patch that matches nothing fails silently rather than loudly.
Incorrect — Only one name exists when patches are applied, and it is the base name. Nothing looks ahead to what the transformers will rename the object to later.
Incorrect — prod-web is a perfectly legal name, and the rendered Deployment uses it. The problem is timing, not syntax, which is why you get no error to warn you.
Incorrect — namePrefix rewrites every object the build produces. That is why both the Service and the Deployment in the rendered output come out named prod-web.

Try this

Run kubectl kustomize app/overlays/prod on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.

Takeaway

The trap worth remembering here: the overlay is not the whole story. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related