Kustomize
Rewritten for this edition in October 2026 for Kubernetes 1.37. Commands checked against the official documentation. Kustomize v5.8.1 (the copy inside kubectl 1.37) and standalone v5.8.3.
Kustomize is the configuration tool built into kubectl that turns one set of plain Kubernetes YAML into a variant per environment, with no templating language. You write a kustomization.yaml that lists your manifests and the changes you want (a name prefix, a namespace, a new image tag, a patch). kubectl kustomize <dir> prints the result and kubectl apply -k <dir> sends it to the cluster. Your original files stay valid YAML that you could apply on their own, which is the whole point.
Below you'll build one small web app as a base with two overlays, dev and prod, and use each field you'll meet in real repositories. The chapter ends with how to choose between Kustomize and Helm.
The Kustomize inside kubectl#
kubectl has carried Kustomize since 1.14. The copy in kubectl 1.37 is Kustomize v5.8.1; the standalone project has moved on to v5.8.3, released on 8 October 2026 (see the Kustomize releases). Your client tells you which one it has:
kubectl version --client
The output looks like this:
Client Version: v1.37.1
Kustomize Version: v5.8.1
That built-in copy is enough for every step here. Two commands are equivalent: kubectl kustomize web/base and the standalone kustomize build web/base. You'd install the separate binary for its edit subcommands, which rewrite kustomization.yaml from a script, or when you need a release newer than your kubectl carries. On macOS it's brew install kustomize; for Linux and Windows, grab a binary from the releases page.
# CI pattern from the Kustomize docs: pin the image to the current commit
kustomize edit set image foo:$(git log -n 1 --pretty=format:"%H")
Before you start#
You need a running cluster and a kubectl that talks to it. A local one is fine: the KinD Cluster chapter gets you there in a couple of minutes, and K3s and K3sup works too. The manifests below are a Deployment, a Service and a ConfigMap, so if any of those feel shaky, reread Kubernetes Deployment and ConfigMaps first.
The layout you'll build#
Create this tree in an empty directory. A base is any directory with a kustomization.yaml; an overlay is another such directory that lists the base under resources and adds changes on top. The base never learns that overlays exist, so one base can feed as many of them as you like.
web/
├── base/
│ ├── kustomization.yaml
│ ├── deployment.yaml
│ ├── service.yaml
│ └── index.html
└── overlays/
├── dev/
│ ├── kustomization.yaml
│ └── index.html
└── prod/
├── kustomization.yaml
├── index.html
└── resources-patch.yaml
Step 1: the base#
The app is stock nginx serving one page, and that page comes from a ConfigMap mounted over nginx's default content directory. Start with web/base/deployment.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
labels:
app: web
spec:
replicas: 1
selector:
matchLabels:
app: web
template:
metadata:
labels:
app: web
spec:
containers:
- name: web
image: nginx:stable
ports:
- containerPort: 80
volumeMounts:
- name: page
mountPath: /usr/share/nginx/html
volumes:
- name: page
configMap:
name: web-page
Then web/base/service.yaml:
apiVersion: v1
kind: Service
metadata:
name: web
labels:
app: web
spec:
selector:
app: web
ports:
- port: 80
targetPort: 80
Put a single line, Hello from the base, into web/base/index.html. Now the file that ties it together, web/base/kustomization.yaml:
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- deployment.yaml
- service.yaml
configMapGenerator:
- name: web-page
files:
- index.html
Render it without touching the cluster:
kubectl kustomize web/base
Part of the output looks like this (your suffix will differ):
apiVersion: v1
data:
index.html: |
Hello from the base
kind: ConfigMap
metadata:
name: web-page-7f2k9c4b8m
---
...
volumes:
- configMap:
name: web-page-7f2k9c4b8m
name: page
Look at both names. The generator appended a hash of the content, and Kustomize rewrote the Deployment's volume to match. Edit index.html and the hash changes, so the pod template changes, and a Deployment rolls out new Pods whenever its .spec.template changes. A hand-written ConfigMap keeps its name when you edit it, so the template stays the same and nothing rolls out. If something outside Kustomize needs a fixed name, generatorOptions: {disableNameSuffixHash: true} turns the suffix off, and you lose the automatic rollout with it.
secretGenerator takes the same files, literals and envs lists and produces an Opaque Secret. Keep real secrets out of Git, though; the generator only base64-encodes what you give it.
Step 2: the dev overlay#
Write Hello from dev into web/overlays/dev/index.html, then web/overlays/dev/kustomization.yaml:
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../../base
namespace: web-dev
namePrefix: dev-
labels:
- pairs:
env: dev
includeTemplates: true
images:
- name: nginx
newTag: mainline
configMapGenerator:
- name: web-page
behavior: replace
files:
- index.html
Five fields, each doing one job:
namespacesetsmetadata.namespaceon every resource. It doesn't add a Namespace object, so you'll createweb-devby hand below.namePrefixturnswebintodev-weband the ConfigMap intodev-web-page-<hash>, with the references updated to match.nameSuffixworks the same way at the other end.- With
labels, theenv: devpair lands on every resource's metadata, andincludeTemplates: truecopies it onto the Pod template too. Selectors stay as the base wrote them, becauseincludeSelectorsdefaults to false. - The
imagesentry matches every container whose image is namednginxand swaps the tag. You can also change the name withnewNameor pin adigest. behavior: replaceon the generator swaps out the base's ConfigMap entirely.mergewould keep the base's keys and add or override yours;create, the default, expects a ConfigMap nobody has made yet.
That third item is where older tutorials go wrong. They use commonLabels, which writes the label into selectors as well, every time. The field was deprecated in Kustomize v5.0.0 in favour of labels. It still works in the v1beta1 API, so old repositories keep building. The danger is real, though: a Deployment's spec.selector is immutable once created, so adding a selector label to something that's already running gets the update rejected.
Build it, read it, then apply:
kubectl create namespace web-dev
kubectl kustomize web/overlays/dev
kubectl apply -k web/overlays/dev
kubectl get -k web/overlays/dev
kubectl port-forward -n web-dev service/dev-web 8080:80
With the port-forward running, curl localhost:8080 from a second terminal should print Hello from dev. If it prints the base's line, check that the overlay's generator says behavior: replace.
Step 3: prod, with both kinds of patch#
Prod needs three Pods, resource requests and a shorter rollout history. Two of those are fields Kustomize doesn't have a shortcut for, which is what patches is for. Write Hello from prod into web/overlays/prod/index.html, then the strategic-merge patch, web/overlays/prod/resources-patch.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
spec:
template:
spec:
containers:
- name: web
resources:
requests:
cpu: 100m
memory: 64Mi
limits:
memory: 128Mi
And web/overlays/prod/kustomization.yaml:
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../../base
namespace: web-prod
namePrefix: prod-
labels:
- pairs:
env: prod
includeTemplates: true
replicas:
- name: web
count: 3
patches:
- path: resources-patch.yaml
- target:
group: apps
version: v1
kind: Deployment
name: web
patch: |-
- op: add
path: /spec/revisionHistoryLimit
value: 5
configMapGenerator:
- name: web-page
behavior: replace
files:
- index.html
The replicas field matches by name alone and only touches Deployments, ReplicaSets, ReplicationControllers and StatefulSets. For anything fancier you'd write a patch.
Both patches sit in one patches list, applied in the order written. The first is a strategic merge: a partial Deployment whose kind and name tell Kustomize what to patch, and whose containers entry is merged into the base's container with the same name. The second is a JSON 6902 patch, a list of operations on paths. It can reach any field of any object, custom resources included, and it always needs a target, because the operations alone don't say which object they're for. Need a / inside a path segment (a label key like app.kubernetes.io/version)? Write it as ~1.
Both patches say web, even though the output is called prod-web. A patch may refer to a resource by any name it has had along the way, so the base name keeps working.
You'll still see patchesStrategicMerge and patchesJson6902 in older repositories. Both were deprecated in v5.0.0 (bases went earlier, in v2.1.0, and vars gave way to replacements). None of them will be in the future v1 Kustomization API. The standalone binary converts a file for you:
cd web/overlays/prod
kustomize edit fix
Preview prod before it exists, then apply:
kubectl create namespace web-prod
kubectl diff -k web/overlays/prod
kubectl apply -k web/overlays/prod
kubectl diff exits with status 1 when it finds differences, which is what you'll get on a first run. In a CI script, check for that exit code on purpose.
Components, briefly#
Overlays stack in a line: prod inherits base, and that's it. Once you need optional features mixed per environment (metrics scraping in prod and staging, say, but not dev), copying the same patch into several overlays gets old. A component is a kustomization with kind: Component that an overlay opts into. Components have existed since Kustomize v3.7.0. Here's one in web/components/scrape/kustomization.yaml:
apiVersion: kustomize.config.k8s.io/v1alpha1
kind: Component
patches:
- target:
kind: Deployment
patch: |-
- op: add
path: /spec/template/metadata/annotations
value:
example.com/scrape: "true"
An overlay pulls it in with a components list next to resources:
components:
- ../../components/scrape
The upstream components guide walks through a larger example with three optional features and three overlays.
Kustomize or Helm#
They answer different questions. Helm is a package manager: charts live in repositories, an install becomes a release with numbered revisions, and helm rollback <release> <revision> takes you back. Kustomize keeps no release record of its own. What you apply is plain YAML, and your Git history is the record of what changed.
| Your situation | Lean towards |
|---|---|
| You own the manifests and environments differ by a handful of fields | Kustomize |
| You're installing third-party software that ships as a chart | Helm |
| You publish an app for other people to configure through values | Helm |
| You need release history and one-command rollback in the cluster | Helm |
| You want to tweak a vendor chart without forking it | Both |
That last row comes up often. Helm can pipe its rendered manifests through a post-renderer such as Kustomize before installing them (see Helm's post rendering docs), so you patch the output and leave the chart alone. Going the other way, a kustomization can inflate a chart through its helmCharts field, which kubectl kustomize only allows with --enable-helm. GitOps tools take either: Argo CD renders any app directory that contains a kustomization.yaml with Kustomize, which you'll use in GitOps and Argo. The Helm side of the comparison starts in Helm Part 1.
Common mistakes#
- Pointing
-kat a file. It takes the directory that holdskustomization.yaml, sokubectl apply -k web/overlays/devworks and the same path ending in/kustomization.yamldoesn't. - Applying to a namespace that doesn't exist yet. The
namespacefield only stamps the name onto your objects, so create the namespace or add a Namespace manifest toresources. - Reaching outside the kustomization root for a loose file, say
../shared/patch.yaml. The default load restrictor,LoadRestrictionsRootOnly, refuses it. Referencing another kustomization directory is fine; a single shared file belongs in its own base or component.--load-restrictor LoadRestrictionsNonelifts the check, and the docs warn it breaks relocatability. - Adding labels to selectors of live objects, whether through
commonLabelsor by flippingincludeSelectorsto true later. Decide selectors on day one. - A JSON 6902 patch with no
target: it has nothing to match.
Clean up#
delete -k removes exactly what the overlay builds, and deleting the namespaces sweeps up the rest:
kubectl delete -k web/overlays/dev
kubectl delete -k web/overlays/prod
kubectl delete namespace web-dev web-prod
Common questions#
A few things the walkthrough didn't need but you'll run into soon.
Can a base live in another Git repository?#
Yes. A base can be a local directory or a directory in a remote repository, as long as it contains a kustomization.yaml. The kubectl reference shows the URL form with a path and a ?ref= pin, for example kubectl kustomize https://github.com/kubernetes-sigs/kustomize.git/examples/helloWorld?ref=v1.0.6. Pin a tag or commit, or the base can change under you.
Will commonLabels stop working?#
Not in kustomize.config.k8s.io/v1beta1: the project says it will never be removed from that API, but it won't be in v1, and v1beta1 itself gets at least two releases' notice before any removal. Migrate now with kustomize edit fix, then read the diff before you commit it.
Where is the full field list?#
The Kustomize reference has a page per field, including labels and its three include flags. The Kubernetes task page, Declarative Management of Kubernetes Objects Using Kustomize, is shorter and covers generators, cross-cutting fields and the -k verbs.
Further watching#
- Video by Anais Urlichs (the original 100 Days of Kubernetes series)