How to Use Kustomize Overlays for Dev, Staging and Prod (2026)
This guide is for engineers who have one set of Kubernetes manifests and need it to run slightly differently in dev, staging and prod without copying the YAML three times. By the end you will have a working Kustomize repository with a shared base, three overlays, both patch styles, per-environment ConfigMaps, a reusable component, a replacement, a CI-friendly build and diff, and an Argo CD ApplicationSet that deploys each overlay. Every example below was rendered with kubectl kustomize on kubectl 1.32.3 (embedded kustomize v5.5.0), and the fields used are unchanged in the current kustomize 5.8.3 and in the 5.8.1 embedded in kubectl 1.36 and 1.37. Prerequisites: kubectl 1.34 or newer, a cluster you are allowed to deploy to, and optionally Argo CD 3.x. If you are still deciding between tools, read Helm vs Kustomize in 2026 first.
Step 1: Check your kustomize version
There are two kustomize binaries in most setups: the one inside kubectl and the standalone one. They drift, and behaviour around namespaces and replacements changed in 5.8.0, so know which one renders your manifests.
kubectl version --client
# Client Version: v1.32.3
# Kustomize Version: v5.5.0
# Optional: the standalone binary, pinned to what Argo CD 3.6 bundles
KUSTOMIZE_VERSION=5.8.3
curl -sSLo kustomize.tar.gz \
"https://github.com/kubernetes-sigs/kustomize/releases/download/kustomize%2Fv${KUSTOMIZE_VERSION}/kustomize_v${KUSTOMIZE_VERSION}_linux_amd64.tar.gz"
tar -xzf kustomize.tar.gz kustomize && ./kustomize version
# v5.8.3
Rule of thumb: in CI, render with the same version your GitOps controller uses. Argo CD 3.6.1 ships kustomize 5.8.3 (tool-versions.sh); Flux's kustomize-controller pins the 5.8.1 API (go.mod).
Step 2: Create the base and overlay layout
The layout below is the convention most teams settle on. The base holds everything shared. Each overlay references the base and states only what differs. Components hold optional features that some overlays switch on.
app/
├── base/
│ ├── kustomization.yaml
│ ├── deployment.yaml
│ └── service.yaml
├── components/
│ └── debug-sidecar/
│ └── kustomization.yaml
└── overlays/
├── dev/kustomization.yaml
├── staging/kustomization.yaml
└── prod/
├── kustomization.yaml
└── deployment-resources.yaml
The base is plain, valid YAML. Note labels with includeSelectors: true, which replaces the deprecated commonLabels and fills in the selector and pod template labels for you:
# app/base/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- deployment.yaml
- service.yaml
labels:
- pairs:
app.kubernetes.io/name: web
includeSelectors: true # also sets Deployment selector, pod labels, Service selector
configMapGenerator:
- name: web-config
literals:
- LOG_LEVEL=info
- FEATURE_X=false
# app/base/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
spec:
replicas: 1
selector:
matchLabels: {} # filled in by labels.includeSelectors
template:
metadata:
labels: {}
spec:
containers:
- name: web
image: ghcr.io/example/web:1.0.0
ports:
- containerPort: 8080
envFrom:
- configMapRef:
name: web-config # kustomize rewrites this to the hashed name
resources:
requests:
cpu: 100m
memory: 128Mi
# app/base/service.yaml
apiVersion: v1
kind: Service
metadata:
name: web
spec:
ports:
- port: 80
targetPort: 8080
Render the base on its own before going further: kubectl kustomize app/base. If the base does not build, no overlay will.
Step 3: Add dev, staging and prod overlays with images and replicas
Most environment differences are a namespace, an image tag and a replica count. Kustomize has dedicated fields for all three, so you need no patches yet. See the images and replicas references.
# app/overlays/staging/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: web-staging
resources:
- ../../base
images:
- name: ghcr.io/example/web # matches the image name in the base, without tag
newTag: 1.1.0
replicas:
- name: web
count: 2
Build it:
kubectl kustomize app/overlays/staging | grep -E 'namespace:|image:|replicas:|name: web-config'
# name: web-config-8tbtckf5t2
# namespace: web-staging
# namespace: web-staging
# namespace: web-staging
# replicas: 2
# name: web-config-8tbtckf5t2
# image: ghcr.io/example/web:1.1.0
In CI, bump tags with the CLI instead of sed, so the file stays well-formed:
cd app/overlays/staging
kustomize edit set image ghcr.io/example/web=ghcr.io/example/web:1.1.1
git diff kustomization.yaml # newTag: 1.1.1
The namespace: field sets the namespace on every namespaced resource but does not create the Namespace. Create it once with kubectl create namespace web-staging, or let Argo CD do it with CreateNamespace=true (Step 9).
Step 4: Patch with strategic merge and JSON 6902
When the dedicated fields run out, use patches. One field takes both styles; the old patchesStrategicMerge and patchesJson6902 fields still work but print deprecation warnings. The patches reference covers the full syntax.
A strategic merge patch looks like a partial resource. Kustomize finds the target by kind and name, then merges. Containers merge by name, which is why the container name must match:
# app/overlays/prod/deployment-resources.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
spec:
template:
spec:
containers:
- name: web # merge key: must match the base container name
resources:
requests:
cpu: 500m
memory: 512Mi
limits:
memory: 512Mi
A JSON 6902 patch is a list of operations on explicit paths. Use it for things a merge cannot express cleanly: replacing a scalar, appending to a list, removing a field.
# app/overlays/prod/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: web-prod
resources:
- ../../base
images:
- name: ghcr.io/example/web
newTag: 1.0.3
patches:
- path: deployment-resources.yaml # strategic merge, target inferred from the file
- target: # JSON 6902, target given explicitly
kind: Deployment
name: web
patch: |-
- op: replace
path: /spec/replicas
value: 4
- op: add
path: /spec/template/spec/topologySpreadConstraints
value:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
app.kubernetes.io/name: web
configMapGenerator:
- name: web-config
behavior: merge
literals:
- LOG_LEVEL=warn
Expected excerpt from kubectl kustomize app/overlays/prod:
spec:
replicas: 4
template:
spec:
containers:
- envFrom:
- configMapRef:
name: web-config-g954k2b5ht
image: ghcr.io/example/web:1.0.3
name: web
resources:
limits:
memory: 512Mi
requests:
cpu: 500m
memory: 512Mi
topologySpreadConstraints:
- labelSelector:
matchLabels:
app.kubernetes.io/name: web
maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: ScheduleAnyway
If you have older overlays, kustomize edit fix rewrites deprecated fields into patches and labels in place. Commit the result separately so the diff is reviewable.
Step 5: Generate per-environment ConfigMaps with configMapGenerator
The base generated web-config. Overlays change it with behavior: merge (add or override keys) or behavior: replace (start over). Without a behavior, kustomize refuses, because two generators with the same name would collide. Details are in the configMapGenerator reference.
# app/overlays/dev/kustomization.yaml (generator part)
configMapGenerator:
- name: web-config
behavior: merge
literals:
- LOG_LEVEL=debug
Each environment gets a different content hash suffix (web-config-tbg555b78d in dev, web-config-8tbtckf5t2 in staging in my render), and kustomize rewrites every reference to it. That is the useful part: changing a value changes the name, which changes the pod template, which triggers a rolling update. A plain ConfigMap edit would not restart anything. Only disable it with options: { disableNameSuffixHash: true } when something outside kustomize must refer to a fixed name.
Step 6: Share optional features with components
Overlays inherit in a line. Components let you mix features in sideways: a debug sidecar for dev, a monitoring setup for staging and prod, and so on. A component uses kind: Component with the v1alpha1 API (components reference):
# app/components/debug-sidecar/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1alpha1
kind: Component
patches:
- target:
kind: Deployment
name: web
patch: |-
- op: add
path: /spec/template/spec/containers/-
value:
name: debug
image: busybox:1.37
command: ["sleep", "infinity"]
# app/overlays/dev/kustomization.yaml (complete)
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: web-dev
resources:
- ../../base
components:
- ../../components/debug-sidecar
images:
- name: ghcr.io/example/web
newTag: 1.1.0-rc.2
configMapGenerator:
- name: web-config
behavior: merge
literals:
- LOG_LEVEL=debug
Rendering dev now shows two containers, web and debug. Staging and prod are untouched because they do not list the component. Kustomize 5.8.2 added kustomize edit remove component to go with the existing add command.
Step 7: Copy values between resources with replacements
replacements copies a field from one resource into others. It replaced the old vars feature. The typical use is a hostname or ID defined once per overlay and injected wherever it is needed (replacements reference):
# app/overlays/staging/kustomization.yaml (additions)
configMapGenerator:
- name: env-values
literals:
- HOSTNAME=web.staging.example.com
replacements:
- source:
kind: ConfigMap
name: env-values
fieldPath: data.HOSTNAME
targets:
- select:
kind: Deployment
name: web
fieldPaths:
- spec.template.metadata.annotations.example\.com/host # escape dots in keys
options:
create: true # create the field if it does not exist
Rendered result:
metadata:
annotations:
example.com/host: web.staging.example.com
Since kustomize 5.8.0, replacements can also edit YAML or JSON embedded inside a string field, such as a config.json key in a ConfigMap. That requires 5.8.0 or later, so it will not work on kubectl 1.35 or older, which embed 5.7.1.
Step 8: Build, diff and apply (kubectl apply -k vs kustomize build)
kubectl apply -k <dir> renders and applies in one step. kubectl kustomize <dir> and kustomize build <dir> only render, which is what you want in CI and code review. The Kubernetes Kustomize task page documents the kubectl side.
# Render every overlay in CI and fail on any error or deprecation warning
set -euo pipefail
for env in dev staging prod; do
kubectl kustomize "app/overlays/${env}" > "/tmp/rendered-${env}.yaml" 2> "/tmp/warn-${env}.txt"
if grep -q 'Warning' "/tmp/warn-${env}.txt"; then cat "/tmp/warn-${env}.txt"; exit 1; fi
done
# Validate against the live API server without changing anything
kubectl apply -k app/overlays/staging --dry-run=server
# See exactly what would change in the cluster
kubectl diff -k app/overlays/staging
# Apply (server-side apply avoids the large last-applied annotation)
kubectl apply -k app/overlays/staging --server-side
Use kubectl diff -k in pull requests against a staging cluster, and attach the rendered YAML as a CI artifact. Reviewers should read the rendered diff, not the overlay diff.
Step 9: Deploy each overlay with Argo CD
Argo CD treats any path containing kustomization.yaml as a Kustomize app (Argo CD Kustomize docs). One ApplicationSet with a Git directory generator creates an Application per overlay. If you have not installed Argo CD yet, the Argo CD setup guide covers it.
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
name: web
namespace: argocd
spec:
goTemplate: true
goTemplateOptions: ["missingkey=error"]
generators:
- git:
repoURL: https://github.com/example/deploy.git
revision: main
directories:
- path: app/overlays/*
template:
metadata:
name: 'web-{{.path.basename}}' # web-dev, web-staging, web-prod
spec:
project: default
source:
repoURL: https://github.com/example/deploy.git
targetRevision: main
path: '{{.path.path}}'
destination:
server: https://kubernetes.default.svc
namespace: 'web-{{.path.basename}}' # matches namespace: in each overlay
syncPolicy:
syncOptions:
- CreateNamespace=true
Keep overrides in the overlay, not in the Application's spec.source.kustomize block, so kubectl kustomize in CI renders the same thing Argo CD does. If an overlay uses helmCharts, Argo CD needs kustomize.buildOptions: --enable-helm in the argocd-cm ConfigMap. Flux does not support helmCharts at all (Flux FAQ); use a HelmRelease there.
Troubleshooting
Each error message below is what kubectl's embedded kustomize printed in my scratch repo when I broke the setup on purpose.
| Symptom | Likely cause | Fix |
|---|---|---|
no resource matches strategic merge patch "Deployment.v1.apps/webb.[noNs]" |
Patch metadata.name or kind does not match any resource in the base |
Use the exact kind and metadata.name from the base, or give the patch an explicit target |
security; file '.../service.yaml' is not in or below '.../overlay' |
Overlay references a file outside its directory tree | Reference a directory with its own kustomization.yaml instead, or (last resort) --load-restrictor LoadRestrictionsNone |
may not add resource with an already registered id: Deployment.v1.apps/web |
Same resource included twice, often a base listed twice or a file in two bases | Remove the duplicate resources entry; each resource must come from exactly one path |
id ... ConfigMap ... web-config exists; behavior must be merge or replace |
Overlay generator reuses a base generator name without behavior |
Add behavior: merge or behavior: replace |
replace operation does not apply: doc is missing path |
JSON 6902 path points at a field or list index that does not exist | Use op: add for new fields, check list indexes, or switch to a strategic merge patch |
must specify --enable-helm |
Kustomization has helmCharts but the build did not enable Helm |
Run kustomize build --enable-helm; in Argo CD set kustomize.buildOptions: --enable-helm in argocd-cm |
accumulating components: ... not a valid directory |
Component path is wrong or the folder has no kustomization.yaml |
Fix the relative path; in Argo CD, ignoreMissingComponents: true skips missing ones deliberately |
Warning: 'commonLabels' is deprecated or 'patchesStrategicMerge' is deprecated |
Old field names from kustomize v4 era | Run kustomize edit fix and commit the rewrite |
| Pods do not restart after a config change | ConfigMap uses disableNameSuffixHash or was created outside kustomize |
Let the generator hash the name so the pod template changes |
| Local render differs from Argo CD | Different kustomize versions (for example kubectl 1.35 embeds 5.7.1, Argo CD 3.6 bundles 5.8.3) | Pin the standalone kustomize in CI to the controller's version |
FAQ
What is the difference between kubectl apply -k and kustomize build?
kustomize build (or kubectl kustomize) only renders manifests to stdout. kubectl apply -k renders and applies in one step. Render separately in CI so you can review and diff the output before anything reaches the cluster.
Should I use strategic merge or JSON 6902 patches?
Use strategic merge for most changes to built-in resources, because it reads like the resource itself and merges lists by key. Use JSON 6902 when you need an exact operation: replacing a scalar, appending to a list with /-, or removing a field. Both go under the same patches field.
Are patchesStrategicMerge and commonLabels removed?
Not removed, but deprecated. kustomize 5.x prints warnings and suggests kustomize edit fix, which rewrites them as patches and labels. Migrate now so the warnings do not hide real errors in CI logs.
How many overlays is too many?
When overlays become near-identical copies that differ by one or two values, stop adding directories by hand. Generate Applications with an ApplicationSet and keep shared changes in components, or consider whether that service should be a Helm chart instead.
When an overlay misbehaves, the first thing I check is the version that rendered it, then the fully rendered output, never the overlay itself: kubectl kustomize overlays/prod | less answers most questions in a minute. In production I keep overlays boring (namespace, image, replicas, a patch or two), run every render in CI with the controller's kustomize version, and treat any deprecation warning as a failed build. The official references are the kustomization field reference, the Kubernetes Kustomize task page and the kustomize releases.
Related: How to Set Up Argo CD for GitOps: Step-by-Step Guide (2026), Helm vs Kustomize in 2026: Templating vs Patching After Helm 4 and How to Fix Helm UPGRADE FAILED Errors: Causes and Commands (2026).
Spot an error or something out of date? Email me and I will fix it and log it on the corrections page.
I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.
Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.
Related
How to Set Up Actions Runner Controller (ARC) on Kubernetes (2026)
Step-by-step ARC 0.15.1 setup for GitHub Actions self-hosted runners on Kubernetes: GitHub App auth, pinned Helm charts, runner scale sets, runner groups, Docker-in-Docker vs kubernetes container mode, scale to zero, metrics, and fixes for jobs stuck in queued.
13 minNov 10, 2026How to Deploy Airflow 3 on Kubernetes with the Helm Chart (2026)
Step-by-step guide to running Apache Airflow 3.4 on Kubernetes with the official Helm chart 1.22.0: KubernetesExecutor vs Celery, git-sync Dags, external Postgres with PgBouncer, Vault secrets, KubernetesPodOperator and upgrading from Airflow 2.
14 minNov 10, 2026How to Build an Azure Pipelines YAML Pipeline: Step-by-Step (2026)
Build an azure-pipelines.yml from scratch in 2026: stages, jobs and steps, reusable templates, variable groups linked to Key Vault, a workload identity federation service connection, environments with approvals, and deployment to AKS or App Service, plus fixes for the common errors.
13 min