CoursesKustomizeBases & overlays

Bases & overlays

dev, staging, prod from one base.

Intermediate14 min · lesson 5 of 12

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.

base/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: 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
base/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: myapp
spec:
replicas: 1 # a sane default; overlays override it
selector:
matchLabels:
app: myapp
template:
metadata:
labels:
app: myapp
spec:
containers:
- name: web
image: ghcr.io/acme/myapp:1.4.0
ports:
- 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.

overlays/prod/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: 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 namespace
namePrefix: 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: myapp
count: 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/ (layout)
myapp/
base/
kustomization.yaml
deployment.yaml
service.yaml
overlays/
dev/kustomization.yaml
staging/kustomization.yaml
prod/kustomization.yaml
terminal
# Render the fully specialized prod manifests to standard output:
kustomize build overlays/prod
output
apiVersion: v1
kind: Service
metadata:
name: prod-myapp
namespace: myapp-prod
spec:
ports:
- port: 80
targetPort: 8080
selector:
app: myapp
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: prod-myapp
namespace: myapp-prod
spec:
replicas: 5
selector:
matchLabels:
app: myapp
template:
metadata:
labels:
app: myapp
spec:
containers:
- image: ghcr.io/acme/myapp:1.4.0
name: web
ports:
- 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.

terminal
# Render the overlay and apply the result to the cluster in one step:
kubectl apply -k overlays/prod
output
service/prod-myapp created
deployment.apps/prod-myapp created
One base, three overlays
base/ (shared)
deployment.yaml
image, port, labels
service.yaml
port 80 to 8080
replicas: 1
generic default
overlays/dev
namespace: myapp-dev
namePrefix: dev-
replicas: 1
inherits base
overlays/staging
namespace: myapp-staging
namePrefix: staging-
replicas: 2
half of prod
overlays/prod
namespace: myapp-prod
namePrefix: prod-
replicas: 5
full load
Every overlay lists ../../base in its resources, then declares only its own deltas. kustomize build overlays/<env> merges the two and prints the finished manifest.

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.

terminal
kubectl get deploy -n myapp-prod
output
NAME READY UP-TO-DATE AVAILABLE AGE
prod-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.

'bases:' is dead, and paths resolve from the overlay
The old bases: field is deprecated. Put base directories in the same resources: list as your files; Kustomize reads a directory entry as a base and a file entry as a plain resource, so there is no separate field anymore. A leftover bases: still builds, but Kustomize prints a deprecation warning on standard error telling you to switch to resources; run 'kustomize edit fix' to migrate it automatically. And every path in resources: resolves from the overlay's own kustomization.yaml, never from your shell's working directory, so ../../base climbs from overlays/prod/ up to myapp/base/ the same way no matter which folder you launch the command from.

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.

terminal
# overlays/prod/kustomization.yaml lists ../../shared-cm.yaml,
# a file that lives ABOVE the overlay's root directory
kustomize build overlays/prod
output
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
Don't reach for --load-restrictor to silence it
You can force the build through with 'kustomize build --load-restrictor LoadRestrictionsNone', and it will happily read the outside file. Resist it. That fence is a security feature: it stops a kustomization you cloned from someone else from quietly reading files elsewhere on your disk. Hitting the error almost always means your layout is wrong, not that you need the escape hatch. Move the shared file inside the tree, or turn it into a small base that both overlays reference, and the restriction stops complaining because nothing lives outside the root anymore.
Quick check
01Your overlays/prod/kustomization.yaml sets namePrefix: prod- and a replicas entry with count: 5, but you wrote name: prod-myapp under replicas instead of name: myapp. You run kustomize build overlays/prod. What comes out?
Incorrect — A replicas entry that matches nothing is skipped without complaint. That silence is the danger: a typo here costs you a wrong count in production rather than a failed pipeline.
Correct — The replica pass compares against the name the base gave the object, so a prefixed name finds nothing and the base value of 1 survives. The prefix pass still runs afterward, so the final name looks right while the count does not.
Incorrect — Renaming happens after the replica pass rather than instead of it, so the prefix is applied either way and the rendered object is prod-myapp. The two passes do not undo each other.
Incorrect — There is no second attempt. One comparison happens, against the name written in base/deployment.yaml, which is why the base name is the one you reference.
02You are sorting values between base/ and overlays/ for the myapp tree. Which of these two placements is the correct one?
Correct — Port 8080 comes from base/deployment.yaml and holds true wherever the app runs, while myapp-prod names one compartment in one cluster, so it stays a prod-only delta.
Incorrect — A prefix exists so prod objects do not collide with anyone else's, and putting it in the base would stamp it on dev too. The image is the part every environment shares, so it belongs in the opposite folder.
Incorrect — A count of 5 is a production load decision, and the app: myapp labels are what wires the Service to its Pods in all three environments. Both of these sit in the wrong place.
Incorrect — Hand that base to a teammate on another cluster and it only applies if myapp-prod already exists there, which is exactly the tie to one place a base has to avoid.
03A pull request deletes the securityContext block from base/deployment.yaml. A colleague waves it through because only the dev overlay is being built this week. Where does that reasoning break down?
Incorrect — Building any overlay renders the base into the result. Run kustomize build overlays/dev and you are reading base/deployment.yaml plus that overlay's changes, so nothing here waits for runtime to appear.
Incorrect — No overlay claims the base. Each one reads it on its own, so the next render of staging or prod carries the same deletion regardless of what was built earlier.
Incorrect — kubectl diff -k points at an overlay directory and builds it, which includes everything the base contributes, so this removal does show up in the diff.
Correct — Blast radius is the whole difference. An overlay edit stops at one environment, while a base edit is already inside all three the moment anyone renders them.

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.

Related