Skip to content

Kustomize

Who this page is for: in plain English, Kustomize takes a set of plain Kubernetes YAML files and applies per-environment patches to them, with no templating language involved. This page is production depth, aimed at readers already comfortable with Pods, Services, and Deployments. New to Kubernetes? Start with the Beginner track.

Kustomize customizes Kubernetes manifests without templates. Instead of sprinkling {{ .Values.replicas }} through your YAML, you keep complete, valid base manifests and declare patches that transform them per environment. It is built into kubectl (kubectl apply -k), which also makes it CKA exam material.

The philosophy difference from Helm matters more than the syntax: a Helm chart is a program that generates YAML; a Kustomize overlay is a diff against YAML that already exists. Diffs are easier to review and reason about; programs are more powerful for packaging and distribution. Most platforms end up using both.

The base/overlay model

app/
├── base/
│   ├── kustomization.yaml
│   ├── deployment.yaml        # complete, valid manifests
│   └── service.yaml
└── overlays/
    ├── staging/
    │   ├── kustomization.yaml # references ../../base + patches
    │   └── replica-patch.yaml
    └── production/
        ├── kustomization.yaml
        ├── replica-patch.yaml
        └── resources-patch.yaml
flowchart LR
    BASE[base/\ncomplete manifests] --> S[overlay: staging\n+ 1 replica, debug logging]
    BASE --> P[overlay: production\n+ 10 replicas, resources,\nprod image tag]
    S --> OUT1[rendered staging YAML]
    P --> OUT2[rendered production YAML]

The base is deployable on its own. Overlays never copy it - they reference and transform it, so a fix to the base propagates to every environment on the next apply.

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

Production overlay

apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
  - ../../base
namespace: production
namePrefix: prod-
images:
  - name: ghcr.io/example/web
    newTag: v2.4.1            # pin the image per environment, no template needed
replicas:
  - name: web
    count: 10
patches:
  - path: resources-patch.yaml

Built-in transformers (namespace, namePrefix, images, replicas, labels) cover the majority of per-environment changes without writing a patch file at all.

Patches: two flavors

Strategic merge patch - a partial manifest; anything you include is merged over the base:

# resources-patch.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: web
spec:
  template:
    spec:
      containers:
        - name: web            # matched by name, not position
          resources:
            requests:
              cpu: 500m
              memory: 1Gi
            limits:
              memory: 1Gi

JSON6902 patch - surgical operations at exact paths, for cases merge semantics can't express (removing a field, editing a list element by index):

patches:
  - target:
      kind: Deployment
      name: web
    patch: |-
      - op: remove
        path: /spec/template/spec/containers/0/livenessProbe
      - op: replace
        path: /spec/strategy/rollingUpdate/maxUnavailable
        value: 0

Rule of thumb: strategic merge for "set these fields," JSON6902 for "remove this" or list-index surgery.

patchesStrategicMerge is deprecated - use patches:

Older Kustomize examples (and plenty of production repos still) use two separate top-level fields:

# the old way -- still works, don't write new code this way
patchesStrategicMerge:
  - resources-patch.yaml
patchesJson6902:
  - target:
      kind: Deployment
      name: web
    path: probe-removal.yaml

Both were folded into a single unified patches: field, where each entry carries either a path/inline patch with strategic-merge content, or a patch: block with JSON6902 operations - the syntax shown earlier in this page. The unification exists because the two old fields had subtly different targeting rules (patchesStrategicMerge matched by the metadata.name/kind inside the patch file; patchesJson6902 required an explicit target: selector), which was a constant source of confusion about why a patch silently didn't apply. patches: supports both patch styles under one consistent target:-based (or metadata-based) matching model.

patchesStrategicMerge and patchesJson6902 aren't removed - Kustomize maintains backward compatibility aggressively - but every current doc and every new feature (like multi-resource targeting via label/annotation selectors in target:) is written against patches:. Migrating existing repos is low-risk and mostly mechanical: fold both old lists into patches:, keeping the JSON6902 entries' target: blocks and adding equivalent target: blocks (by kind+name) to the former strategic-merge entries.

Generators: the killer feature

configMapGenerator and secretGenerator create ConfigMaps/Secrets with a content-hash suffix, and rewrite every reference to match:

configMapGenerator:
  - name: app-config
    files:
      - app.properties
    literals:
      - LOG_LEVEL=info

This produces app-config-7b2f8c9k4d, and the Deployment that mounts app-config is automatically updated to reference the hashed name. Change app.properties, and the hash changes - which changes the pod template - which triggers a normal rolling update.

This is the clean solution to the problem described in ConfigMaps and Secrets: editing a ConfigMap in place never restarts pods, and subPath mounts never update at all. Generated, hash-named config makes every config change a versioned, rollback-able rollout instead of a silent in-place mutation. If you adopt one Kustomize feature, adopt this one.

(Use generatorOptions: {disableNameSuffixHash: true} only when something outside Kustomize references the ConfigMap by fixed name.)

Components: optional, reusable pieces

Bases and overlays solve "every environment inherits this." They don't cleanly solve "some environments opt into this feature, others don't" - that requires either duplicating the base per combination or writing the same patch into multiple overlays. components/ is Kustomize's answer.

A component is structurally similar to a base (it has its own kustomization.yaml with apiVersion: kustomize.config.k8s.io/v1alpha1 and kind: Component), but instead of being inherited automatically, overlays opt in explicitly by listing it under components::

app/
├── base/
│   ├── kustomization.yaml
│   └── deployment.yaml
├── components/
│   ├── tracing/
│   │   ├── kustomization.yaml     # kind: Component
│   │   └── tracing-sidecar-patch.yaml
│   └── debug-logging/
│       ├── kustomization.yaml
│       └── log-level-patch.yaml
└── overlays/
    ├── staging/
    │   └── kustomization.yaml     # opts into debug-logging, not tracing
    └── production/
        └── kustomization.yaml     # opts into tracing, not debug-logging
# components/tracing/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1alpha1
kind: Component
patches:
  - path: tracing-sidecar-patch.yaml
    target:
      kind: Deployment
      name: web
# overlays/production/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
  - ../../base
components:
  - ../../components/tracing

The distinction that matters: a base is always inherited by every overlay that references it - it's the shared foundation. A component is opt-in per overlay - it's a feature toggle, not a foundation. Before components existed, teams either forked a patch into every overlay that needed it (drift-prone) or built one giant base with conditionals baked into values a template engine would resolve (which defeats the point of using a template-free tool). Components are the correct home for cross-cutting, optional concerns: a tracing sidecar some environments want, a network policy some clusters enforce, a debug log-level patch you only want in staging.

replacements: - propagating values between resources

The older vars: field let you extract a field from one resource and substitute it as a string, $(VAR_NAME)-style, elsewhere in the tree:

# the old way -- deprecated, avoid in new configs
vars:
  - name: SERVICE_NAME
    objref:
      kind: Service
      name: web
    fieldref:
      fieldpath: metadata.name

vars: had real limitations: it only worked as opaque string substitution (no type awareness - injecting a number or a list required string-munging), it ran as a distinct, oddly-ordered phase relative to patches, and it couldn't target a field precisely by structured path on the receiving side. replacements: replaces it with a structured source→target model:

replacements:
  - source:
      kind: ConfigMap
      name: app-config
      fieldPath: data.API_VERSION
    targets:
      - select:
          kind: Deployment
          name: web
        fieldPaths:
          - spec.template.spec.containers.[name=web].env.[name=API_VERSION].value
      - select:
          kind: Ingress
          name: web
        fieldPaths:
          - spec.rules.0.http.paths.0.path

This says: take the value at data.API_VERSION in the app-config ConfigMap, and write it into two different fields on two different resources, addressed by exact structured path (including the [name=X] selector syntax for finding an entry in a list by a field value, not by index). Because it operates on typed field paths rather than string tokens, replacements: composes correctly with generators - it's the mechanism you reach for when a hash-suffixed generated name (a ConfigMap or Secret name Kustomize itself mutated) needs to be threaded into a field a built-in transformer doesn't already rewrite for you, or when a Service name needs to land inside a custom resource's spec that Kustomize doesn't understand natively (see configurations: below for that case).

Remote bases: Git URLs as resources

A resources: entry doesn't have to be a local path - it can be a Git URL, which Kustomize clones and builds from directly:

resources:
  - github.com/example-org/platform-charts/base/web?ref=v2.4.1

This is powerful for sharing a canonical base across many downstream repos without vendoring or a package manager - teams reference "the platform base at this tag" the same way they'd reference a library version. It has real reproducibility implications, though. ?ref= can point at a branch, a tag, or a commit SHA:

?ref= value Reproducibility
a commit SHA fully pinned - identical output every build, forever
a tag (e.g. v2.4.1) pinned in practice, but tags are mutable in Git - someone can force-move one
a branch (e.g. main) not pinned at all - every kustomize build can silently pick up new commits

Referencing a branch is the equivalent of an unpinned latest image tag: convenient during active co-development, dangerous in anything that needs to be reproducible or auditable. For production overlays, pin to a commit SHA or, at minimum, an immutable tag, the same discipline you'd apply to a container image digest. Also budget for the operational cost: kustomize build now depends on network access and the remote repo's availability at build time (including in CI and inside Argo CD/Flux's reconciliation loop) - a rate-limited or unreachable Git host turns into a failed sync. Vendoring (checking out the remote base into your own repo via CI, rather than referencing it live) trades convenience for a hard guarantee that reconciliation never depends on a third-party endpoint being up.

configurations: - teaching Kustomize about CRDs

Kustomize's built-in transformers (name reference updates, label propagation, the images:/replicas: fields) know how to walk the fields of core Kubernetes kinds. They don't know anything about your CRDs by default - if a custom resource has a field that references another object by name (a common CRD pattern: spec.targetRef.name pointing at a Service, or spec.secretName pointing at a generated Secret), Kustomize won't update that field when a namePrefix, nameSuffix, or generator hash changes the name it points to.

configurations: points at a file describing extra fields for Kustomize's transformers to consider:

# kustomization.yaml
configurations:
  - kustomizeconfig.yaml
# kustomizeconfig.yaml
nameReference:
  - kind: MyCustomResource
    version: v1
    fieldSpecs:
      - path: spec/targetRef/name
        kind: Deployment
varReference:
  - path: spec/config
    kind: MyCustomResource

nameReference entries tell the name-reference transformer: "when a Deployment gets renamed (prefix, suffix, or hash suffix), also update spec/targetRef/name on any MyCustomResource that pointed at it." Without this, renaming resources via namePrefix: or a generator hash silently breaks references inside CRDs that Kustomize doesn't natively understand - the base resources are correct, the generated ConfigMap is correct, but the custom resource pointing at the old name is now dangling, and nothing errors; it just fails at reconcile time in whatever operator consumes the CRD. This is the sharp edge every team hits the first time they combine configMapGenerator (hash suffixes) with an Operator's CRD that references that ConfigMap by name - if the CRD field isn't taught to Kustomize via configurations:, the hash rotates but the reference doesn't follow it.

Ordering and merge semantics

When multiple patches target the same resource, order matters and the rules are worth understanding precisely rather than by trial and error:

  • Patches inside a single patches: list are applied in list order, top to bottom. A later patch sees the result of every earlier patch, not the original base resource.
  • Strategic merge patches within that sequence merge additively on maps (new keys are added, matching keys are overwritten by the later patch) and replace lists wholesale unless a merge key (like a container's name) lets Kustomize match list elements individually - which is why the containers: - name: web pattern shown earlier works: Kustomize matches the container by name and merges into that element rather than replacing the whole containers: array.
  • JSON6902 operations are positional and literal - path: /spec/template/spec/containers/0/... addresses container index 0 as it exists at the point that patch runs in the sequence, which includes any reordering earlier patches caused. Two JSON6902 patches that both add a container at index 0 will not do what you expect; the second one displaces the first rather than adding a second entry.
  • Across bases and components, resources accumulate depth-first in resources:/components: list order, then all patches: in the current kustomization.yaml apply on top of the fully-assembled resource set - patches never apply "inside" a referenced base before that base's own output is merged in.
  • Generators (configMapGenerator, secretGenerator) run, and hash suffixes are computed, before the name-reference transformer rewrites other resources to point at the hashed name - which is exactly why configurations: (above) matters: any field the name-reference transformer doesn't know about misses that rewrite entirely, regardless of ordering.

The practical rule that follows from all of this: when two patches touch the same field, the later one in the list always wins, and when you're not sure what a chain of patches produces, don't reason about it from the YAML - run kubectl kustomize <dir> (or kustomize build) and read the actual rendered output. It is cheap, fast, and authoritative in a way that mentally simulating the merge is not.

Using it

Kustomize is built into kubectl:

kubectl kustomize overlays/production          # render, review
kubectl apply -k overlays/production           # render + apply
kubectl diff -k overlays/production            # what would change
kubectl delete -k overlays/production

The standalone kustomize binary tracks newer features (and is what Argo CD and Flux embed); kubectl's built-in version lags it slightly. For CI, render with kustomize build and pipe through policy checks before applying.

Composing with Helm and GitOps

  • Argo CD and Flux speak Kustomize natively - point an Application at an overlay directory and you have per-environment GitOps with zero templating.
  • The common hybrid: Helm for third-party software (you consume someone's chart), Kustomize for your own apps (you own the YAML). Kustomize can even post-process rendered charts via helmCharts: when you need to patch a chart the maintainer won't parameterize.
  • Because overlays are plain YAML in Git, git diff on a PR shows exactly what changes in production - the review experience is Kustomize's quiet superpower.

Kustomize in CI/GitOps: rendering, policy, and repo layout

Argo CD and Flux both call kustomize build internally as part of every reconciliation - but relying solely on the controller to catch a bad overlay means the first place a mistake surfaces is a failed or dangerous sync against a real cluster. A CI pipeline should render and validate before anything reaches Argo CD or Flux.

A typical pre-merge pipeline for a PR touching an overlay:

# 1. Render every overlay that could have changed
for env in staging production; do
  kustomize build overlays/$env > /tmp/rendered-$env.yaml
done

# 2. Validate against the Kubernetes API schema (no cluster required)
kubeconform -strict -summary /tmp/rendered-production.yaml

# 3. Policy-check the rendered output before it's applied anywhere
conftest test /tmp/rendered-production.yaml -p policy/
# or, if you run Kyverno/OPA Gatekeeper in the cluster already:
kyverno apply policy/ --resource /tmp/rendered-production.yaml

# 4. Diff against what's currently live, for human review on the PR
kubectl diff -k overlays/production || true

The key property this gives you: policy violations, schema errors, and unintended diffs show up as a failed CI check on a PR, not as a rejected admission webhook during a GitOps sync at 2am, or worse, as something an admission controller doesn't happen to catch because the policy only runs in one cluster. Rendering in CI also lets you catch the "remote base moved" class of problem (see the reproducibility note above) before it reaches a live sync.

Repo layout: per-environment directories vs. per-environment branches. Two patterns dominate in practice:

  • Directory-per-environment (overlays/staging/, overlays/production/, all on main) is what this page has shown throughout. A single PR can touch multiple environments' overlays at once, git diff shows every environment's change in one review, and there's exactly one branch to keep in sync. This is the pattern Argo CD's and Flux's own documentation examples default to, and it's the right default for most teams.
  • Branch-per-environment (a staging branch and a production branch, promoted by merging one into the other) maps naturally onto "promote this exact commit through environments" and pairs well with a GitOps controller configured to track a specific branch per environment. Its cost is real: merge conflicts between environment branches when the same file diverges, and the constant temptation to hotfix directly on the production branch outside the promotion flow, which quietly defeats the reason for having the pattern.

Directory-per-environment scales better for teams with more than two or three environments (branch fan-out gets unwieldy fast) and keeps promotion visible as an ordinary PR diff. Branch-per-environment can suit a simpler two-environment setup with a strict linear promotion story. Whichever you pick, keep it consistent across the org - mixing both patterns across different repos is a frequent source of GitOps confusion during incident response, when someone is trying to figure out under time pressure which directory or branch actually governs what's running.

Kustomize vs Helm

Kustomize Helm
Model patch existing YAML render templates
Learning curve low medium (Go templates, Sprig)
Environment variants overlays values files
Packaging/distribution none (it's just Git) charts, registries, versioning
Rollback tooling via Git / GitOps helm rollback release history
Config-change rollouts hash-suffixed generators checksum annotations (manual pattern)
Built into kubectl yes (-k) no

Choose Kustomize when you own the manifests and want reviewable, template-free environment variants. Choose Helm when you're distributing software to others or consuming third-party packages. They compose rather than compete.

Common mistakes

  • Copying the base into each overlay - the entire point is single-source transformation; copies rot immediately.
  • Deep overlay chains (overlay-on-overlay-on-overlay): after two levels, nobody can predict the output. Keep it to base + one overlay per environment and verify with kubectl kustomize.
  • Patching what a transformer already handles - use images: and replicas: fields instead of hand-written patches for those.
  • Disabling the hash suffix out of habit - you're throwing away automatic config rollouts.
  • Referencing a remote base by branch instead of a pinned tag or SHA - ?ref=main means every build can pull in changes nobody reviewed for this environment; pin it the same way you'd pin an image digest.
  • Forgetting configurations: for CRD name references - a configMapGenerator hash rotates, the built-in transformer updates every Deployment that mounts it, but a custom resource's spec.someField.name pointing at the same ConfigMap silently goes stale because Kustomize doesn't know that field exists without a nameReference entry.
  • Mixing vars: and replacements: in the same tree out of inertia - vars: still works but is deprecated and untyped; new propagation logic should be written as replacements:, and it's worth migrating old vars: blocks when you touch them anyway.
  • Two JSON6902 patches inserting at the same list index - patches apply in sequence against the current state, not the original base, so a second add at /spec/.../containers/0 displaces rather than stacks with the first. Render and check the actual output rather than assuming both applied.

Certification notes

  • The current CKA curriculum explicitly includes Kustomize (alongside Helm) under cluster and workload configuration. Know kubectl apply -k, the kustomization.yaml structure, and how overlays reference bases.
  • kubectl kustomize <dir> (render without applying) is the fast way to verify your answer in the exam.