Bases & overlays
dev, staging, prod from one base.
Old anatomy textbooks had a clever trick. One printed page showed the skeleton. Laid over it were clear plastic sheets, each adding a single layer: muscles on one, veins on the next, skin on the last. Flip a sheet down and you saw the same body with one more layer of detail. Nobody redrew the skeleton to make the muscle diagram. They drew only the difference and laid it on top. Kustomize works the same way. It customizes configuration files for Kubernetes (the system that runs your app's containers across a cluster of machines) without any templating language, and that clear-sheet picture is exactly what the word overlay means.
Your skeleton is the base: a complete set of Kubernetes objects (a Deployment, which keeps copies of your app running; a Service, which gives those copies one stable network address; maybe a ConfigMap, a Kubernetes object that holds plain configuration values) describing everything your app needs, with nothing that ties it to one place. Each clear sheet is an overlay: a short file that says start from that base, then change these few things for this environment. Dev gets one copy of the app and chatty logs. Staging gets two. Prod gets five and a name prefix so its objects never collide with anyone else's in a shared cluster. Nothing is copied. The base stays the one true source, and every environment is written only as its difference from it.
That difference is usually tiny, a handful of lines, and that is the whole point. The smaller the overlay, the less there is to drift, forget, or get wrong when you promote a change from dev to prod. If you run security or operations, this is the property you want: one place to read what the app actually is, and a short, reviewable set of changes describing what each environment bends. When the diff for prod is three lines, a reviewer can hold all three lines in their head.
The base: what every environment shares
A base is a complete, self-contained, deployable set of resources plus its own kustomization.yaml (the small control file, written in YAML, a plain-text data format, that tells Kustomize which files to include and how to change them). That last part trips people up. A base is not a loose pile of manifests sitting in a folder. It is itself a valid kustomization that names the files it owns. Build it on its own and you get working YAML for a plain, un-specialized deployment that could run anywhere.
Keep the base to the common denominator: the things that are true in every environment. The image name, the container port, the labels that connect a Service to its Pods (a Pod is the smallest unit Kubernetes runs, one or more containers together). The moment you write a value that is right for only one place, a prod-only replica count, a dev-only namespace, that value belongs in an overlay instead. Here is a fast test. Could you hand this base to a teammate on a different cluster and have it apply cleanly? If it only works because your dev namespace happens to already exist, it is not really a base yet.
apiVersion: kustomize.config.k8s.io/v1beta1kind: Kustomization# A base is a real kustomization: it names the raw files it owns.# These resources are what EVERY environment shares, unspecialized.resources:- deployment.yaml # generic Deployment: image, port, 1 replica- service.yaml # generic Service on port 80
apiVersion: apps/v1kind: Deploymentmetadata:name: myappspec:replicas: 1 # a sane default; overlays override itselector:matchLabels:app: myapptemplate:metadata:labels:app: myappspec:containers:- name: webimage: ghcr.io/acme/myapp:1.4.0ports:- containerPort: 8080
Overlays: only the difference
An overlay is another kustomization.yaml, and its first job is to point at the base. It does that by listing the base in its resources, as a directory path, not a file. Below that one line sits only what makes this environment different: a distinct namespace (a named compartment that isolates objects inside the cluster) so its objects live apart, a name prefix so prod and dev never share an object name in a shared cluster, and a replica count tuned to the load. Kustomize reads the base, lays your changes on top, and prints the merged result. The overlay never edits the base's files. It declares deltas, the differences, and leaves the base alone.
apiVersion: kustomize.config.k8s.io/v1beta1kind: Kustomization# Point at the base by RELATIVE DIRECTORY path (not a file).resources:- ../../base# Everything below is only what makes prod differ from the base:namespace: myapp-prod # isolate prod objects in their own namespacenamePrefix: prod- # avoid name collisions in a shared cluster# Bump replicas for production load. Match by the ORIGINAL base# name (myapp). The prefix is applied in a separate pass, so this# still finds the object and the output is renamed to prod-myapp.# (Surgical, field-level edits are ku-patches' territory.)replicas:- name: myappcount: 5
One subtlety is worth reading twice. The replicas rule matches name: myapp, the name from the base, even though the object comes out named prod-myapp. Kustomize applies the replica change and the name prefix in separate passes, and the replica pass runs first, so you always refer to the original base name here, not the prefixed one. Get that backwards, write name: prod-myapp, and the rule matches nothing while the count silently stays at the base's value of one. Nothing errors. You quietly ship the wrong number of copies to production.
This separation is why bumping prod to five leaves dev and staging untouched. They do not share the overlay. They share the base. Change prod's overlay and the only environment that moves is prod.
Building each environment
Each environment is a different directory you point the build at. Same command, different folder, different result. Here is the layout and the command that renders prod.
myapp/base/kustomization.yamldeployment.yamlservice.yamloverlays/dev/kustomization.yamlstaging/kustomization.yamlprod/kustomization.yaml
# Render the fully specialized prod manifests to standard output:kustomize build overlays/prod
apiVersion: v1kind: Servicemetadata:name: prod-myappnamespace: myapp-prodspec:ports:- port: 80targetPort: 8080selector:app: myapp---apiVersion: apps/v1kind: Deploymentmetadata:name: prod-myappnamespace: myapp-prodspec:replicas: 5selector:matchLabels:app: myapptemplate:metadata:labels:app: myappspec:containers:- image: ghcr.io/acme/myapp:1.4.0name: webports:- containerPort: 8080
That is the whole idea. No branching in Git to hold three copies, no templating language to learn, no folders that drift apart over months. The image, the port, and the labels always come from the base. The namespace, the prefix, and the replica count come from the overlay. Aim the same build at overlays/dev and you get a single replica in the myapp-dev namespace, named dev-myapp, from the exact same source of truth. When you are ready, render and apply in one step. The -k flag tells kubectl to run Kustomize on the directory first.
# Render the overlay and apply the result to the cluster in one step:kubectl apply -k overlays/prod
service/prod-myapp createddeployment.apps/prod-myapp created
Reading it like a defender
The rendered manifest is your audit surface. Whatever kustomize build prints is exactly what reaches the cluster, so make the build the thing you review, not the scattered source files. Render it, read it, and compare it against what is live before you apply. kubectl diff -k overlays/prod builds the overlay and shows only what would change against the running objects, which is the fastest way to catch a replica count someone fat-fingered or an image tag that moved when it should have stayed put. A clean diff before apply is worth more than a clean-looking source file.
Consider where a bad change does the most damage: the base. Every environment includes it, so one edit there, a swapped image, a mounted host path, a dropped securityContext (the block that sets a container's privileges), lands in dev, staging, and prod at the same time. Overlays are loud and local. A base change is quiet and global. In code review, treat a diff that touches base/ as higher risk than one touching a single overlay, and read the rendered prod build rather than only the file that changed, since an overlay can add or strip resources too.
After you apply, confirm the shape you expected actually landed. Asking the cluster is more honest than trusting the YAML you meant to send. This should show prod-myapp with five of five Pods ready.
kubectl get deploy -n myapp-prod
NAME READY UP-TO-DATE AVAILABLE AGEprod-myapp 5/5 5 5 37s
If you instead see a plain myapp with no prefix, your namePrefix did not run, which almost always means the change went into the base by mistake instead of the overlay. The verify step turns a silent misconfiguration into an obvious one.
There is one guard you will meet the first time you try to share a single file across two trees. Think of it as a fence around the overlay's folder. By default, a kustomization cannot load anything that sits outside its own root directory. Reference a YAML file that lives above or beside your tree and the build refuses.
# overlays/prod/kustomization.yaml lists ../../shared-cm.yaml,# a file that lives ABOVE the overlay's root directorykustomize build overlays/prod
Error: accumulating resources: accumulation err='accumulating resources from '../../shared-cm.yaml': security; file '/home/you/myapp/shared-cm.yaml' is not in or below '/home/you/myapp/overlays/prod'': must build at directory: '/home/you/myapp/shared-cm.yaml': file is not directory
One habit pays for itself. In continuous integration (the automated pipeline that checks every change before it merges), render every overlay with kustomize build and fail the job if any of them exits non-zero. A base that stops building breaks all three environments at once, and you want that caught in a pull request, not at three in the morning during a prod rollout.
Try this
Run kustomize build 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: 'bases:' is dead, and paths resolve from the overlay. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.