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:
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:
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:
# 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 thecontainers: - name: webpattern shown earlier works: Kustomize matches the container bynameand merges into that element rather than replacing the wholecontainers:array. - JSON6902 operations are positional and literal -
path: /spec/template/spec/containers/0/...addresses container index0as 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 index0will 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 allpatches:in the currentkustomization.yamlapply 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 whyconfigurations:(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 diffon 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 onmain) is what this page has shown throughout. A single PR can touch multiple environments' overlays at once,git diffshows 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
stagingbranch and aproductionbranch, 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 theproductionbranch 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:andreplicas: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=mainmeans 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 - aconfigMapGeneratorhash rotates, the built-in transformer updates everyDeploymentthat mounts it, but a custom resource'sspec.someField.namepointing at the same ConfigMap silently goes stale because Kustomize doesn't know that field exists without anameReferenceentry. - Mixing
vars:andreplacements:in the same tree out of inertia -vars:still works but is deprecated and untyped; new propagation logic should be written asreplacements:, and it's worth migrating oldvars: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
addat/spec/.../containers/0displaces 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.
Source Links¶
- Kustomize documentation
- kubernetes-sigs/kustomize on GitHub and its releases
kustomization.yamlfield reference- Kubernetes: declarative management with Kustomize
- Kustomize built-in transformer plugins
kubectlversion skew and the embedded Kustomize version- Argo CD Kustomize support
- Flux Kustomization API
Related Concepts¶
- Helm - the complementary packaging approach
- ConfigMaps and Secrets - the update problem generators solve
- Argo CD - GitOps delivery for overlays
- OPA Gatekeeper - policy-checking rendered overlays in CI or admission
- Kyverno - alternative policy engine for the same purpose
- CKA Exam Guide