Lesson 59 of 66 · 14 min read · Updated

Kustomize — Environment Overlays Without Templates

In the previous article we packaged the Catalog API as a Helm chart. Helm solves the multi-environment problem with templates: you write {{ .Values.replicaCount }} in the manifest and fill it in per environment. That is powerful, but it has a cost. The files in your repository are no longer valid Kubernetes YAML, and reading a chart means reading Go templates.

Kustomize takes the opposite approach. Every file stays plain, valid YAML. You write the manifests once as a base, and each environment is an overlay that says what to change: a different namespace, a different image tag, three replicas instead of one, an extra Secret. Kustomize is built into kubectl, so there is nothing to install.

In this article we deploy the same Catalog API to a development and a production namespace with Kustomize, and test every feature on a real cluster.

We will cover the following topics:

  1. Bases, overlays and the kustomization.yaml file
  2. Namespaces, name prefixes and labels
  3. The images transformer for changing tags without editing manifests
  4. Strategic merge patches and JSON 6902 patches
  5. ConfigMap and Secret generators, and why their names end in a hash
  6. Components for optional, reusable features
  7. kubectl kustomize, kubectl apply -k, kubectl diff -k and pruning with ApplySets
  8. When to choose Kustomize and when to choose Helm

Technical Requirements

  1. A Kubernetes cluster and kubectl. The examples run on Docker Desktop's built-in Kubernetes with the kind provisioner (1 control-plane node and 3 workers, Kubernetes v1.36). A plain kind cluster with the same layout behaves the same.
  2. kubectl 1.36 ships with Kustomize v5.8.1 built in. Check yours with kubectl version --client.
  3. The catalog-api:1.0.0 and catalog-api:1.1.0 images from the previous article. On a plain kind cluster, load them with kind load docker-image catalog-api:1.0.0 catalog-api:1.1.0.

Core Concepts

Base and Overlays

A kustomization is a folder containing a file called kustomization.yaml. That file lists resources and the transformations to apply to them. A kustomization can use another kustomization as a resource, which is how layering works.

LayerContainsExample
BaseThe manifests every environment sharesDeployment, Service, default config
OverlayOne environment's differences, plus a reference to the basenamespace: catalog-prod, 3 replicas, a Secret
ComponentAn optional feature that several overlays can opt intoA PodDisruptionBudget only production wants

The folder layout used in this article:

kustomize/
base/
kustomization.yaml
deployment.yaml
service.yaml
components/
pdb/
kustomization.yaml
pdb.yaml
overlays/
dev/
kustomization.yaml
prod/
kustomization.yaml
replicas-resources.yaml

The base never knows that overlays exist. Overlays point down at the base with a relative path such as ../../base. By default Kustomize refuses to load files from outside the kustomization's own folder tree, except through resources and components entries. That is the load restrictor, and it is a security feature, not a bug.

Transformers

Most of what an overlay does is set a field. Each of these fields is a built-in transformer:

FieldEffect
namespaceSets metadata.namespace on every resource. It does not create the namespace.
namePrefix / nameSuffixRenames every resource and updates references to them (a Deployment's configMapRef, for example)
labelsAdds labels. includeSelectors: false keeps them out of selectors, which are immutable on a Deployment.
imagesChanges the tag, digest or name of a container image wherever it appears
replicasSets replica counts by resource name

The reference-updating part is what makes Kustomize more than sed. Rename a ConfigMap and every Deployment that mounts it is updated to match.

Avoid the older commonLabels field. It also adds labels to selectors, and changing a selector label on an existing Deployment fails, because selectors cannot be changed after creation. The labels field with includeSelectors: false is the safe replacement.

Two Kinds of Patches

When a transformer is not enough, you patch.

Patch typeLooks likeBest for
Strategic merge patchA partial Kubernetes manifestChanging or adding fields, merging into lists by key (containers merge by name)
JSON 6902 patchA list of op/path/value operationsPrecise edits: replacing one list element by index, appending to a list, removing a field

Both go under the patches field. A patch either sits in its own file (path:) or inline (patch:), and a target selects which resources it applies to by kind, name, label or annotation.

Generators and Hash Suffixes

configMapGenerator and secretGenerator build ConfigMaps and Secrets from literals, files or .env files. Kustomize appends a hash of the content to the name, for example catalog-config-hc622gmtk2, and rewrites every reference to use that name.

This fixes a real problem. Pods read environment variables from a ConfigMap only when they start. If you edit a ConfigMap in place, running Pods never notice. With a hash suffix, new content means a new name, the Deployment's Pod template changes, and Kubernetes rolls the Pods. It is the same idea as the checksum/config annotation in the Helm chart, done for you.

An overlay can extend a generator defined in the base with behavior: merge (add or override keys) or replace it entirely with behavior: replace.

Remember that secretGenerator only base64-encodes values. If the literal is in your Git repository, the secret is in your Git repository. For real secrets, use the Vault integration covered earlier in this course or a tool such as Sealed Secrets or External Secrets.

Components

A component is a kustomization of kind Component. It can add resources and apply patches, but it is only included where an overlay lists it under components. Use components for features that are optional rather than environment-specific: a PodDisruptionBudget, a debug sidecar, an OpenTelemetry configuration.

Hands-On: Kubernetes Commands

Render an overlay without applying it

Prints the final YAML. Always look at this before applying.

kubectl kustomize overlays/prod

Apply an overlay

kubectl apply -k overlays/prod

See what would change on the cluster

Shows a diff between the live objects and the rendered overlay. Exits with code 1 when there are differences, which is handy in CI.

kubectl diff -k overlays/prod

Delete everything an overlay created

kubectl delete -k overlays/dev

Apply with pruning through an ApplySet

Deletes objects that were applied earlier through the same ApplySet but are no longer rendered. ApplySet support in kubectl is still alpha, hence the environment variable.

KUBECTL_APPLYSET=true kubectl apply -k overlays/prod --prune --applyset=catalog-prod -n catalog-prod

Check the built-in Kustomize version

kubectl version --client

Step-by-Step Example

The Scenario

The Catalog API must run in two namespaces. Development runs one replica of the newest image, 1.1.0, with the ASP.NET Core environment set to Development. Production runs three replicas of the stable 1.0.0 image, with larger resources, a database connection string from a Secret, and a PodDisruptionBudget. Both share one set of base manifests.

Step 1 — The Base

base/deployment.yaml is a complete, valid manifest. You could kubectl apply -f it on its own.

apiVersion: apps/v1
kind: Deployment
metadata:
name: catalog-api
spec:
replicas: 1
selector:
matchLabels:
app: catalog-api
template:
metadata:
labels:
app: catalog-api
spec:
containers:
- name: catalog-api
image: catalog-api:1.0.0
imagePullPolicy: IfNotPresent
ports:
- name: http
containerPort: 8080
env:
- name: ASPNETCORE_ENVIRONMENT
value: Production
envFrom:
- configMapRef:
name: catalog-config
readinessProbe:
httpGet:
path: /healthz
port: http
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
memory: 256Mi

base/service.yaml:

apiVersion: v1
kind: Service
metadata:
name: catalog-api
spec:
selector:
app: catalog-api
ports:
- name: http
port: 80
targetPort: http

base/kustomization.yaml lists the resources, adds a label to everything, and generates the ConfigMap the Deployment refers to as catalog-config:

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

resources:
- deployment.yaml
- service.yaml

labels:
- pairs:
app.kubernetes.io/part-of: catalog
includeSelectors: false

configMapGenerator:
- name: catalog-config
literals:
- Catalog__Greeting=Hello from catalog-api

As in the Helm article, Catalog__Greeting becomes the .NET configuration key Catalog:Greeting.

Step 2 — The Development Overlay

overlays/dev/kustomization.yaml uses five features at once: a namespace, a name prefix, the images transformer, a generator merge, and a JSON 6902 patch that replaces the first environment variable's value.

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

namespace: catalog-dev
namePrefix: dev-

resources:
- ../../base

labels:
- pairs:
environment: dev

images:
- name: catalog-api
newTag: 1.1.0

configMapGenerator:
- name: catalog-config
behavior: merge
literals:
- Catalog__Greeting=Hello from the dev overlay

patches:
- target:
kind: Deployment
name: catalog-api
patch: |-
- op: replace
path: /spec/template/spec/containers/0/env/0/value
value: Development

Render it:

kubectl kustomize overlays/dev
apiVersion: v1
data:
Catalog__Greeting: Hello from the dev overlay
kind: ConfigMap
metadata:
labels:
app.kubernetes.io/part-of: catalog
environment: dev
name: dev-catalog-config-hg56f9dbt7
namespace: catalog-dev
---
apiVersion: v1
kind: Service
metadata:
labels:
app.kubernetes.io/part-of: catalog
environment: dev
name: dev-catalog-api
namespace: catalog-dev
spec:
ports:
- name: http
port: 80
targetPort: http
selector:
app: catalog-api
---
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
app.kubernetes.io/part-of: catalog
environment: dev
name: dev-catalog-api
namespace: catalog-dev
spec:
replicas: 1
selector:
matchLabels:
app: catalog-api
template:
metadata:
labels:
app: catalog-api
spec:
containers:
- env:
- name: ASPNETCORE_ENVIRONMENT
value: Development
envFrom:
- configMapRef:
name: dev-catalog-config-hg56f9dbt7
image: catalog-api:1.1.0
...

Check each change against the overlay. The ConfigMap was renamed twice: dev- from the prefix and -hg56f9dbt7 from the hash. The Deployment's configMapRef was rewritten to match. The image tag is 1.1.0 although no manifest says so. And the selector still only contains app: catalog-api, because the labels were added with includeSelectors: false.

Step 3 — The Production Overlay

Production needs more resources and three replicas. That is a strategic merge patch: a partial Deployment, merged into the base by name. Containers merge by their name, so only catalog-api's resources change.

overlays/prod/replicas-resources.yaml:

apiVersion: apps/v1
kind: Deployment
metadata:
name: catalog-api
spec:
replicas: 3
template:
spec:
containers:
- name: catalog-api
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
memory: 512Mi

The connection string comes from a generated Secret. A JSON 6902 add operation with a path ending in /- appends the Secret to the existing envFrom list. A strategic merge patch could not do this cleanly, because envFrom entries have no merge key.

overlays/prod/kustomization.yaml:

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization

namespace: catalog-prod

resources:
- ../../base

components:
- ../../components/pdb

labels:
- pairs:
environment: prod

images:
- name: catalog-api
newTag: 1.0.0

configMapGenerator:
- name: catalog-config
behavior: merge
literals:
- Catalog__Greeting=Hello from production

secretGenerator:
- name: catalog-db
literals:
- ConnectionStrings__Catalog=Host=postgres;Database=catalog;Username=catalog;Password=change-me

patches:
- path: replicas-resources.yaml
- target:
kind: Deployment
name: catalog-api
patch: |-
- op: add
path: /spec/template/spec/containers/0/envFrom/-
value:
secretRef:
name: catalog-db

The Secret is referenced as plain catalog-db in the patch. Kustomize rewrites it to the hashed name, just like the ConfigMap.

Step 4 — A Component for the PodDisruptionBudget

Only production wants a PodDisruptionBudget. Put it in a component so any future overlay, such as staging, can opt in with one line.

components/pdb/kustomization.yaml:

apiVersion: kustomize.config.k8s.io/v1alpha1
kind: Component

resources:
- pdb.yaml

components/pdb/pdb.yaml:

apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: catalog-api
spec:
minAvailable: 2
selector:
matchLabels:
app: catalog-api

The component receives the overlay's transformations, so the PDB lands in catalog-prod with the environment: prod label without saying so itself.

Render production and pick out the interesting lines:

kubectl kustomize overlays/prod
kind: ConfigMap
name: catalog-config-hc622gmtk2
kind: Secret
name: catalog-db-hm4md8f9bf
kind: Service
name: catalog-api
kind: Deployment
name: catalog-api
replicas: 3
- configMapRef:
name: catalog-config-hc622gmtk2
- secretRef:
name: catalog-db-hm4md8f9bf
image: catalog-api:1.0.0
kind: PodDisruptionBudget
name: catalog-api

Step 5 — Apply Both Overlays

The namespace field sets the namespace on resources but does not create it, so create the namespaces first. Alternatively, add a Namespace manifest to each overlay's resources.

kubectl create namespace catalog-dev
kubectl create namespace catalog-prod
kubectl apply -k overlays/dev
kubectl apply -k overlays/prod
configmap/dev-catalog-config-hg56f9dbt7 created
service/dev-catalog-api created
deployment.apps/dev-catalog-api created
configmap/catalog-config-hc622gmtk2 created
secret/catalog-db-hm4md8f9bf created
service/catalog-api created
deployment.apps/catalog-api created
poddisruptionbudget.policy/catalog-api created
kubectl rollout status deploy/catalog-api -n catalog-prod
deployment "catalog-api" successfully rolled out

kubectl get deploy,cm,secret,pdb,svc -n catalog-prod
NAME READY UP-TO-DATE AVAILABLE AGE
deployment.apps/catalog-api 3/3 3 3 11s

NAME DATA AGE
configmap/catalog-config-hc622gmtk2 1 11s
configmap/kube-root-ca.crt 1 11s

NAME TYPE DATA AGE
secret/catalog-db-hm4md8f9bf Opaque 1 11s

NAME MIN AVAILABLE MAX UNAVAILABLE ALLOWED DISRUPTIONS AGE
poddisruptionbudget.policy/catalog-api 2 N/A 1 11s

NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
service/catalog-api ClusterIP 10.96.254.65 <none> 80/TCP 11s

Step 6 — Verify Each Environment

Port-forward both Services in two terminals:

kubectl -n catalog-dev port-forward svc/dev-catalog-api 8081:80
kubectl -n catalog-prod port-forward svc/catalog-api 8082:80
curl http://localhost:8081/
{"service":"catalog-api","version":"1.1.0","environment":"Development","greeting":"Hello from the dev overlay","pod":"dev-catalog-api-7bf54bc7db-cvwg6"}

curl http://localhost:8082/
{"service":"catalog-api","version":"1.0.0","environment":"Production","greeting":"Hello from production","pod":"catalog-api-6f97846bcb-7594q"}

Different version, environment and greeting, from one set of base manifests. Confirm that the Secret reached the production container:

kubectl -n catalog-prod exec deploy/catalog-api -- printenv ConnectionStrings__Catalog
Host=postgres;Database=catalog;Username=catalog;Password=change-me

In .NET that value is available as builder.Configuration.GetConnectionString("Catalog").

Step 7 — Change Configuration and Watch the Hash

Change the production greeting in overlays/prod/kustomization.yaml to Hello from production, v2, then diff before applying:

kubectl diff -k overlays/prod
- name: catalog-config-hc622gmtk2
+ name: catalog-config-dkhd449ch5
...
+apiVersion: v1
+data:
+ Catalog__Greeting: Hello from production, v2
+kind: ConfigMap
+metadata:
+ labels:
+ app.kubernetes.io/part-of: catalog
+ environment: prod
+ name: catalog-config-dkhd449ch5
+ namespace: catalog-prod

The diff tells the whole story: a new ConfigMap, and the Deployment pointing at it. Apply:

kubectl apply -k overlays/prod
configmap/catalog-config-dkhd449ch5 created
secret/catalog-db-hm4md8f9bf configured
service/catalog-api unchanged
deployment.apps/catalog-api configured
poddisruptionbudget.policy/catalog-api configured

kubectl rollout status deploy/catalog-api -n catalog-prod
deployment "catalog-api" successfully rolled out

The Pods rolled, and they now read the new greeting. Look at the ConfigMaps, though:

kubectl get cm -n catalog-prod
NAME DATA AGE
catalog-config-dkhd449ch5 1 13s
catalog-config-hc622gmtk2 1 37s
kube-root-ca.crt 1 37s

The old one is still there. kubectl apply only creates and updates; it never deletes something you stopped rendering. Keeping the previous ConfigMap is useful for a rollback, but over months they pile up.

Step 8 — Prune Stale Objects with an ApplySet

An ApplySet is a small parent object, a Secret by default, that records which objects belong to one apply. With --prune, kubectl deletes members that are no longer in the rendered output. It is still alpha in kubectl, so it is enabled with an environment variable.

KUBECTL_APPLYSET=true kubectl apply -k overlays/prod --prune --applyset=catalog-prod -n catalog-prod

Change the greeting again, to v3, and apply with the same command:

configmap/catalog-config-m8f4b292kt created
secret/catalog-db-hm4md8f9bf configured
service/catalog-api unchanged
deployment.apps/catalog-api configured
poddisruptionbudget.policy/catalog-api configured
configmap/catalog-config-dkhd449ch5 pruned
kubectl get cm -n catalog-prod
NAME DATA AGE
catalog-config-hc622gmtk2 1 45s
catalog-config-m8f4b292kt 1 1s
kube-root-ca.crt 1 45s

The v2 ConfigMap was pruned. The very first one, hc622gmtk2, survived, because it was applied before the ApplySet existed and was never labelled as a member. Adopt an ApplySet from the first apply, or delete pre-existing leftovers once by hand. In the next article, Argo CD does this pruning for you.

Step 9 — Clean Up

kubectl delete -k deletes exactly what the overlay renders:

kubectl delete -k overlays/dev
configmap "dev-catalog-config-hg56f9dbt7" deleted from catalog-dev namespace
service "dev-catalog-api" deleted from catalog-dev namespace
deployment.apps "dev-catalog-api" deleted from catalog-dev namespace

Then remove both namespaces, which also removes the leftover ConfigMap and the ApplySet Secret:

kubectl delete namespace catalog-dev catalog-prod

Helm or Kustomize?

They solve the same problem from opposite ends, and many teams use both.

HelmKustomize
Files in GitGo templates, not valid YAMLPlain, valid Kubernetes YAML
CustomizationOnly what the chart author exposed in values.yamlAny field of any resource, through patches
Packaging and versioningCharts with SemVer, published to OCI registriesNone; you share folders or Git references
Release history and rollbackBuilt in (helm history, helm rollback)None; use Git history or a GitOps tool
Hooks (migrations, tests)Built inNone; use Jobs or a GitOps tool's sync hooks
InstallingSeparate CLIBuilt into kubectl
Best forDistributing an application for others to install; third-party softwareYour own services across your own environments

A common pattern combines them: install third-party software such as cert-manager or Prometheus from its Helm chart, and manage your own .NET services with Kustomize overlays. You can also render a chart with helm template and use the output as a Kustomize resource, to patch the parts the chart author did not expose.

Summary

  1. A base holds the shared manifests; an overlay references the base and declares one environment's differences. Every file stays plain, valid YAML.
  2. Built-in transformers set the namespace, name prefixes, labels and image tags, and update references when names change. Use labels with includeSelectors: false instead of commonLabels.
  3. Strategic merge patches look like partial manifests and merge lists by key; JSON 6902 patches make precise edits such as appending with /-.
  4. Generators create ConfigMaps and Secrets with a content hash suffix, so a configuration change automatically rolls the Pods.
  5. Components package optional features, such as a PodDisruptionBudget, that overlays opt into.
  6. kubectl apply -k never deletes old objects. Use kubectl diff -k before applying, and an ApplySet with --prune to remove what you stopped rendering.
  7. The namespace field does not create the namespace.

In the next article we stop running kubectl apply by hand altogether and let Argo CD keep the cluster in sync with Git.