Patches: strategic merge & JSON6902
Change exactly what differs.
You changed one number, not the whole file. That is a patch: a short, declarative note that says "in this environment, this one field is different," laid over a base you never edit. Think of a recipe binder in a busy kitchen. The base recipe stays in the binder. For tonight's catering job you don't rewrite the card, you clip a sticky note on top: double the salt, skip the garnish. Everyone still reads the original; the note only records what changed. Kustomize patches are that sticky note for Kubernetes (the system that runs your containers) manifests.
Kustomize gives you two kinds of sticky note. A strategic-merge patch is a partial copy of the manifest where you write only the fields you want to change, and Kustomize folds it into the base like blending two maps. A JSON6902 patch (named after RFC 6902, one of the numbered Request for Comments documents that define internet standards; this one specifies "JSON Patch," where JSON is the JavaScript Object Notation text format) is an ordered list of tiny operations: add this, replace that, remove the other, each aimed at an exact address inside the document. Same goal, two very different failure modes, and for a security overlay the failure mode is the whole story.
Strategic Merge: Write The Diff, Let Kustomize Fold It In
A strategic-merge patch has to say who it is talking about. It carries only enough identity, the apiVersion, the kind, and metadata.name (the group/version/kind/name, or GVKN, that names a resource), so Kustomize can find the matching base resource. Everything else in the patch is the change. Maps merge by key: a field you set is overwritten, a field you leave out is untouched, a field that did not exist is added. Lists are the sharp edge. Some lists carry a merge key, so Kustomize matches items by that key instead of by position. For containers the key is name; for a container's env (environment) variables the key is name too. That is why you can reach into one container by name and change its resources without disturbing the others.
apiVersion: apps/v1kind: Deploymentmetadata:name: payments-apispec:replicas: 1selector:matchLabels:app: payments-apitemplate:metadata:labels:app: payments-apispec:containers:- name: appimage: registry.internal/payments-api:1.4.2ports:- containerPort: 8080- name: debug-sidecarimage: registry.internal/netshoot:latest
Here is the prod overlay's sticky note. It hardens the app container and peels off the debug sidecar that has no business running in production. Notice it names both containers by name, sets fields on one, and deletes the other with the $patch directive.
apiVersion: apps/v1kind: Deploymentmetadata:name: payments-apispec:replicas: 4template:spec:containers:- name: appresources:limits:cpu: "2"memory: 1Girequests:cpu: 500mmemory: 512MisecurityContext:runAsNonRoot: trueallowPrivilegeEscalation: falsereadOnlyRootFilesystem: truecapabilities:drop: ["ALL"]- name: debug-sidecar$patch: delete
apiVersion: kustomize.config.k8s.io/v1beta1kind: Kustomizationresources:- ../../basepatches:- path: patch-harden.yaml
kustomize build overlays/prod
apiVersion: apps/v1kind: Deploymentmetadata:name: payments-apispec:replicas: 4selector:matchLabels:app: payments-apitemplate:metadata:labels:app: payments-apispec:containers:- image: registry.internal/payments-api:1.4.2name: appports:- containerPort: 8080resources:limits:cpu: "2"memory: 1Girequests:cpu: 500mmemory: 512MisecurityContext:allowPrivilegeEscalation: falsecapabilities:drop:- ALLreadOnlyRootFilesystem: truerunAsNonRoot: true
Read the render, not the patch. Two things jump out. The debug-sidecar container is gone, because $patch: delete removes a list item by its merge key. And Kustomize prints the fields alphabetically, so image lands before name and the securityContext keys come out sorted, which is why the rendered order never matches the order you typed. The app container kept its image and ports (fields you never mentioned) and gained the resource limits plus the securityContext (the block that tells Kubernetes to run the process as a non-root user, forbid privilege escalation, mount the root filesystem read-only, and drop every Linux capability, the fine-grained root-like powers a process can hold). That securityContext is the reason this patch exists. It is your hardening, expressed as a diff, and it landed on the right container because the name matched.
JSON6902: Edit By Address, Fail Out Loud
A JSON6902 patch does not carry a copy of the manifest. It carries directions. Each operation names an op (add, remove, replace, move, copy, or test) and a path written as a JSON Pointer (RFC 6901: slash-separated keys, where /- means "append to this list" and list items are addressed by number). In Kustomize you attach it to a target selector instead of putting the name in the body. Because the address is literal, JSON6902 is the tool when you must hit a list item by position, delete a field outright, or make a change that merge semantics would fumble. Here is a slice of the same work written as operations.
apiVersion: kustomize.config.k8s.io/v1beta1kind: Kustomizationresources:- ../../basepatches:- target:kind: Deploymentname: payments-apipatch: |-- op: replacepath: /spec/replicasvalue: 6- op: removepath: /spec/template/spec/containers/1
kustomize build overlays/prod
apiVersion: apps/v1kind: Deploymentmetadata:name: payments-apispec:replicas: 6selector:matchLabels:app: payments-apitemplate:metadata:labels:app: payments-apispec:containers:- image: registry.internal/payments-api:1.4.2name: appports:- containerPort: 8080
Two things to notice. First, /spec/template/spec/containers/1 addresses the sidecar by its slot in the list, which is exact but brittle: reorder the base and index 1 is now a different container. Second, JSON6902 fails loudly. Point an op at an address that is not there (change that 1 to a 5, a slot no container occupies) and the render stops cold.
kustomize build overlays/prod
Error: error in remove for path: '/spec/template/spec/containers/5': Unable to access invalid index: 5: invalid index referenced
That is the mirror image of the strategic-merge trap. Strategic merge quietly adds when it cannot find a match; JSON6902 refuses to render. One risk is a silent wrong result, the other is a broken build you notice at once. For a path key that contains a slash, such as an annotation, escape it as ~1 (and a literal ~ as ~0), so config.kubernetes.io/depends-on becomes .../config.kubernetes.io~1depends-on inside the path.
Which One, And When
Reach for strategic merge for most edits: setting resources, adding a securityContext, changing an image or a replica count. It reads like the manifest and merges maps without ceremony. Reach for JSON6902 when you need surgical control: removing a field entirely, targeting a list item by index, or applying one patch across many resources through a target with a labelSelector. Both live in kustomization.yaml, so the overlay stays an honest record of what it changes. The older split fields patchesStrategicMerge and patchesJson6902 still work, but the unified patches list with a target is what current Kustomize prefers.
Prove The Patch Did What You Think
A patch is small, and small changes are exactly what a reviewer skims. That is the opening an attacker wants. A three-line strategic-merge patch can add privileged: true to a container; a one-line JSON6902 remove can strip the securityContext you depend on; an add can mount a hostPath, a slice of the node's own filesystem, straight into the pod. The patch file looks innocent. The rendered result does not. So your control is to review the render, not the note. Build the overlay and check that the fields you care about are actually present, and that the ones you removed are actually gone.
kustomize build overlays/prod | grep -E 'replicas:|runAsNonRoot|readOnlyRootFilesystem|allowPrivilegeEscalation'kustomize build overlays/prod | grep -c 'debug-sidecar'
replicas: 4allowPrivilegeEscalation: falsereadOnlyRootFilesystem: truerunAsNonRoot: true0
The grep confirms the hardening rendered (in alphabetical order, the way Kustomize emits it), and the count of 0 proves the sidecar delete took effect. Then push the rendered YAML through a server-side dry run, which validates it against the live API (application programming interface) without changing anything. This is where the phantom-container mistake finally surfaces: a container with no image is rejected here even though kustomize build was perfectly happy to emit it.
kustomize build overlays/prod | kubectl apply --dry-run=server -f -
deployment.apps/payments-api configured (server dry run)
Wire both checks into continuous integration (CI, the automated tests that run on every change): render the overlay, fail the build if a banned field appears or a required one is missing, and feed that same rendered YAML to a policy scanner. The patch file is only the intent. The rendered manifest is what the cluster actually runs, and it is the one artifact worth trusting.
Try this
Run kustomize build overlays/prod on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.
Takeaway
The trap worth remembering here: a Wrong Name Appends, It Does Not Error. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.