Build & apply
kustomize build / kubectl -k.
kustomize build turns your source files into finished Kubernetes YAML. The files you edit never touch a cluster on their own: the kustomization.yaml, the bases it pulls in, the patches that override a few fields, and the generators that build a ConfigMap or Secret. build reads that source, resolves every reference, runs every transformer in the right order, and prints one flat stream of plain Kubernetes YAML. A transformer is any step that rewrites the manifests along the way, like stamping on a name prefix or a label. Nothing is live until you hand that stream to the API server.
Keeping the two steps apart, render then apply, is the one habit that makes Kustomize safe to run when you are tired or in a hurry. Render produces something you can read. Apply is the part you cannot take back. Separating them buys you a moment to look before anything changes in a shared cluster.
Render first, apply second
kustomize build <dir> prints the fully assembled manifests to stdout and changes nothing. This build is your dry run and your review surface at once. Read the output closely, because the flat result often looks nothing like the small overlay you edited. A base you inherit, a component you opted into, a name prefix, a content hash a generator stapled onto a ConfigMap: all of them show up only after assembly, not before.
$ kustomize build overlays/prod # render only, changes nothing
apiVersion: v1kind: Servicemetadata:name: prod-payments-apinamespace: paymentsspec:ports:- port: 80targetPort: 8080selector:app: payments-api---apiVersion: apps/v1kind: Deploymentmetadata:name: prod-payments-apinamespace: paymentsspec:replicas: 4selector:matchLabels:app: payments-apitemplate:metadata:labels:app: payments-apispec:containers:- image: registry.internal/payments-api:2.1.0name: appports:- containerPort: 8080
The overlay you edited was a handful of lines: a namespace, a name prefix, a replica count, an image tag. Yet the build above is the whole Service and Deployment with every one of those edits already baked in. When the rendered YAML matches what you expect, apply it. There are two ways to do that.
$ kustomize build overlays/prod | kubectl apply -f - # explicit: render, then apply$ kubectl apply -k overlays/prod # build + apply folded into one
service/prod-payments-api createddeployment.apps/prod-payments-api created
kubectl apply -k folds build and apply into a single command because Kustomize ships inside kubectl (the command-line tool you use to talk to a Kubernetes cluster). It is fine for a quick change. The explicit pipe (the | that feeds build's output straight into apply) does the same work but keeps the render in your hands: you can save it, scan it, or diff it before a single byte reaches the API server. For anything that matters, prefer the pipe, because the version you can read is the version you can review.
The artifact is your review surface
Reading the whole rendered output is the review step that catches the changes a small diff hides. A patch is a partial file. A remote base is code you did not write. Either one can set a field you would never approve if you saw it spelled out: a securityContext (the part of a pod spec that sets its privileges) flipped to privileged: true, a hostPath volume (a mount of a directory from the node's own filesystem straight into the pod) that reaches the host, an extra ClusterRole (a cluster-wide grant of permissions) that widens RBAC (role-based access control, the rules that decide who can do what in the cluster), or an image quietly swapped for one nobody has scanned. None of that is visible in the two-line overlay. All of it is visible in the build output.
Render, then read the assembled artifact, or at least grep it for the fields that decide privilege, before you apply. In a pull request, review the rendered YAML, not the overlay change alone, because the assembled result is what reaches the cluster.
$ kustomize build overlays/prod | grep -nE 'privileged|hostPath|allowPrivilegeEscalation'
52: privileged: true
That one line was contributed by a base three directories away. The overlay diff never mentioned it. The rendered artifact did.
Two kustomize engines, different versions
Most machines carry two Kustomize engines that are not the same version. One is the standalone kustomize binary you install yourself. The other is a copy compiled into kubectl, which is what runs when you type kubectl -k or kubectl kustomize. The embedded copy usually trails the standalone release, sometimes by a year. A newer field, an updated API version, or the --enable-helm flag that lets a kustomization inflate a Helm chart during the build can exist in one and be missing from the other.
$ kustomize version # the standalone binary$ kubectl version --client # look at the embedded Kustomize Version line
v5.4.3Client Version: v1.29.6Kustomize Version: v5.0.4-0.20230601165947-6ce0bf390ce3
A kustomization that builds cleanly under the standalone kustomize can fail or render differently under kubectl -k on the same laptop, and differently again on a teammate's older kubectl. For anything past a quick apply, install the standalone binary, pin its exact version in CI, render with it, and pipe plain YAML to kubectl apply -f -. That decouples what you render from whichever kubectl happens to be installed, so the same commit renders to the same bytes on every laptop and every runner. If the render is not deterministic, you cannot say what you actually deployed.
Preview against the live cluster
kubectl diff -k <dir> is the closest thing Kustomize gives you to a plan. It renders the overlay, then shows exactly which live objects would change and how, as an ordinary unified diff (the same before-and-after format git shows you). Nothing is applied. Read it like a code review of the running cluster.
$ kubectl diff -k overlays/prod
diff -u -N /tmp/LIVE-2891543/apps.v1.Deployment.payments.prod-payments-api /tmp/MERGED-4417820/apps.v1.Deployment.payments.prod-payments-api--- /tmp/LIVE-2891543/apps.v1.Deployment.payments.prod-payments-api 2026-07-17 09:14:02.117 +0000+++ /tmp/MERGED-4417820/apps.v1.Deployment.payments.prod-payments-api 2026-07-17 09:14:02.121 +0000@@ -8,6 +8,6 @@name: prod-payments-apinamespace: paymentsspec:- replicas: 2+ replicas: 4selector:matchLabels:@@ -34,6 +34,6 @@spec:containers:- name: app- image: registry.internal/payments-api:2.0.4+ image: registry.internal/payments-api:2.1.0ports:- containerPort: 8080
diff exits 0 when nothing would change and 1 when there is a difference (anything above 1 means the command itself failed). That makes it a clean gate. A pipeline step can fail loudly when a change you thought was a no-op actually moves something in production. In CI, go one step further and render every overlay on every commit, so a broken reference or a mistyped patch fails the pipeline instead of the cluster. Archive the rendered YAML as the artifact for the change: the exact bytes that will be applied.
$ for o in overlays/*/; do kustomize build "$o" >/dev/null || exit 1; done$ echo $?
0
Keep the build inside its root
A kustomization can read files off disk. That is fine for your own tree, but a remote base you inherit is code from someone else, and you do not want it reading files from outside its own directory. --load-restrictor defaults to LoadRestrictionsRootOnly, which forbids reading any file above the kustomization root. Watch what happens when a base tries to climb out.
apiVersion: kustomize.config.k8s.io/v1beta1kind: KustomizationconfigMapGenerator:- name: pulledenvs:- ../../secret.env # a file at the repo root, outside this overlay
$ kustomize build overlays/evil
error: loading KV pairs: env source files: [../../secret.env]: security; file '/home/deploy/payments/secret.env' is not in or below '/home/deploy/payments/overlays/evil'
The default fence stopped it. Now loosen it, and the same build happily slurps that outside file into a ConfigMap you would then apply. This is exactly how a hostile base exfiltrates a secret or a host file into an object on your cluster: it asks the build to read something outside its tree, and a relaxed restrictor says yes.
$ kustomize build --load-restrictor LoadRestrictionsNone overlays/evil | grep -A1 '^data:'
data:API_TOKEN: super-secret
Keep the default. Treat any kustomization that needs LoadRestrictionsNone as a thing to read line by line before you trust it. One more flag earns its place in pipelines: -o writes the rendered manifests to a path instead of stdout. Point it at a directory (which must already exist) and Kustomize writes one file per object, named by group, version, kind, and name.
$ mkdir -p rendered$ kustomize build overlays/prod -o rendered/$ ls rendered/
apps_v1_deployment_prod-payments-api.yamlv1_service_prod-payments-api.yaml
After you apply, check that the render actually landed. kubectl get -k overlays/prod builds the overlay and lists exactly the objects it declares, so anything running that this get does not show is an orphan from an earlier, larger build. kubectl rollout status deploy/prod-payments-api -n payments confirms the new image rolled instead of silently sticking on the old pods. The build told you what should be true; those two commands tell you whether it is.
Try this
Run kustomize build overlays/prod # render only, changes nothing 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: apply -k never deletes what you removed. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.