Skip to content

Kustomize

RewrittenChapter 18 of 388 min read

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:

  • namespace sets metadata.namespace on every resource. It doesn't add a Namespace object, so you'll create web-dev by hand below.
  • namePrefix turns web into dev-web and the ConfigMap into dev-web-page-<hash>, with the references updated to match. nameSuffix works the same way at the other end.
  • With labels, the env: dev pair lands on every resource's metadata, and includeTemplates: true copies it onto the Pod template too. Selectors stay as the base wrote them, because includeSelectors defaults to false.
  • The images entry matches every container whose image is named nginx and swaps the tag. You can also change the name with newName or pin a digest.
  • behavior: replace on the generator swaps out the base's ConfigMap entirely. merge would 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 situationLean towards
You own the manifests and environments differ by a handful of fieldsKustomize
You're installing third-party software that ships as a chartHelm
You publish an app for other people to configure through valuesHelm
You need release history and one-command rollback in the clusterHelm
You want to tweak a vendor chart without forking itBoth

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 -k at a file. It takes the directory that holds kustomization.yaml, so kubectl apply -k web/overlays/dev works and the same path ending in /kustomization.yaml doesn't.
  • Applying to a namespace that doesn't exist yet. The namespace field only stamps the name onto your objects, so create the namespace or add a Namespace manifest to resources.
  • 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 LoadRestrictionsNone lifts the check, and the docs warn it breaks relocatability.
  • Adding labels to selectors of live objects, whether through commonLabels or by flipping includeSelectors to 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#