How to Use Kustomize Overlays for Dev, Staging and Prod (2026)

Nov 10, 2026 · 12 min · Ajay Kumar

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.

One base, three overlays, one component, rendered in CI and synced by Argo CD base/ deployment, service labels, configMapGenerator image web:1.0.0, 1 replica components/debug-sidecar kind: Component adds a busybox container overlays/dev web-dev, tag 1.1.0-rc.2 component, LOG_LEVEL=debug overlays/staging web-staging, tag 1.1.0, 2 replicas hostname replacement overlays/prod web-prod, tag 1.0.3, 4 replicas merge + JSON 6902 patches CI: kubectl kustomize render every overlay fail on warnings, diff -k Argo CD ApplicationSet git generator app/overlays/* web-dev, web-staging, web-prod Render with the kustomize version your controller runs: 5.8.3 in Argo CD 3.6, 5.8.1 in kubectl 1.36 and 1.37.
The repository built in Steps 2 to 9. Version numbers are from the Argo CD tool-versions file and kubectl go.mod at each release tag.

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