CoursesKustomizeBuild & apply

Build & apply

kustomize build / kubectl -k.

Intermediate10 min · lesson 4 of 12

kustomize build is the compiler for your Kubernetes manifests. A manifest is a YAML file that describes what you want running in the cluster (YAML is a plain-text format for configuration). The files you edit are source code: the kustomization.yaml, the bases it pulls in, the patches that override a few fields, the generators that build a ConfigMap or Secret (its cousin for sensitive values) for you. On their own they never touch a cluster, the same way a source file never runs until a compiler turns it into a program. 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. That printed stream is the finished artifact. Nothing is live until you hand it to the API server, the cluster's control-plane front door that every change has to pass through.

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. stdout is standard output, the normal text stream a command writes to your terminal. 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. An overlay is the thin directory of edits you work in day to day. 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. A base is the shared set of manifests your overlay builds on; a component is an optional, reusable slice of config; a ConfigMap is a Kubernetes object that stores configuration as key-value pairs.

terminal
$ kustomize build overlays/prod # render only, changes nothing
output
apiVersion: v1
kind: Service
metadata:
name: prod-payments-api
namespace: payments
spec:
ports:
- port: 80
targetPort: 8080
selector:
app: payments-api
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: prod-payments-api
namespace: payments
spec:
replicas: 4
selector:
matchLabels:
app: payments-api
template:
metadata:
labels:
app: payments-api
spec:
containers:
- image: registry.internal/payments-api:2.1.0
name: app
ports:
- containerPort: 8080

The overlay you edited was a handful of lines: a namespace, a name prefix, a replica count, an image tag. A namespace is a named partition that keeps one group of cluster objects apart from another. Yet the build above is the whole Service and Deployment with every one of those edits already baked in. A Service is the stable network address that sits in front of your pods; a Deployment is the controller that keeps a set of identical pods running, a pod being the smallest thing Kubernetes schedules, one or more containers that live and die together. When the rendered YAML matches what you expect, apply it. There are two ways to do that.

terminal
$ kustomize build overlays/prod | kubectl apply -f - # explicit: render, then apply
$ kubectl apply -k overlays/prod # build + apply folded into one
output
service/prod-payments-api created
deployment.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.

From source files to live objects
1Source files
kustomization.yaml, bases, patches, generators
2kustomize build
resolve refs, run transformers in order
3Rendered artifact
one flat stream of plain YAML on stdout
4Review / diff
read it, or kubectl diff -k against live
5kubectl apply
hand the artifact to the API server
6Cluster
objects created or updated (never deleted)

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.

So the defender's move is boring and it works: render, then read the assembled artifact, or at least grep it (search the text for a pattern) for the fields that decide privilege before you apply. In a pull request (a proposed change your teammates review before it merges), review the rendered YAML, not the overlay change alone, because the assembled result is what reaches the cluster.

terminal
$ kustomize build overlays/prod | grep -nE 'privileged|hostPath|allowPrivilegeEscalation'
output
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. It is like keeping two dictionaries of different printings: a word can exist in the new one and be missing from the old. Here, the word might be a newer field, an updated API version, or the --enable-helm flag that lets a kustomization inflate a Helm chart (a packaged, templated bundle of Kubernetes manifests) during the build.

terminal
$ kustomize version # the standalone binary
$ kubectl version --client # look at the embedded Kustomize Version line
output
v5.4.3
Client Version: v1.29.6
Kustomize Version: v5.0.4-0.20230601165947-6ce0bf390ce3

Two printings, one box. 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 (continuous integration, the automated pipeline that runs on every change), 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 (a CI machine that executes the pipeline). For a security control that is the whole point: 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.

terminal
$ kubectl diff -k overlays/prod
output
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-api
namespace: payments
spec:
- replicas: 2
+ replicas: 4
selector:
matchLabels:
@@ -34,6 +34,6 @@
spec:
containers:
- name: app
- image: registry.internal/payments-api:2.0.4
+ image: registry.internal/payments-api:2.1.0
ports:
- 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.

terminal
$ for o in overlays/*/; do kustomize build "$o" >/dev/null || exit 1; done
$ echo $?
output
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. A fence around a kitchen makes the rule concrete: the cooks can use anything on the counter, but they cannot walk out to the street and grab whatever they like. --load-restrictor is that fence. It defaults to LoadRestrictionsRootOnly, which forbids reading any file above the kustomization root. Watch what happens when a base tries to climb out.

overlays/evil/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
configMapGenerator:
- name: pulled
envs:
- ../../secret.env # a file at the repo root, outside this overlay
terminal
$ kustomize build overlays/evil
output
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.

terminal
$ kustomize build --load-restrictor LoadRestrictionsNone overlays/evil | grep -A1 '^data:'
output
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.

terminal
$ mkdir -p rendered
$ kustomize build overlays/prod -o rendered/
$ ls rendered/
output
apps_v1_deployment_prod-payments-api.yaml
v1_service_prod-payments-api.yaml
apply -k never deletes what you removed
Both kubectl apply -k and a piped apply -f - only create or update the objects present in the current build. They never delete a resource you removed from the kustomization. Drop a Service from resources, re-apply, and the old Service keeps running in the cluster, orphaned and forgotten. There is no prune tied to a kustomization. kubectl apply --prune exists, but it is label-scoped and easy to misfire, sweeping up more than you meant. Delete removed objects explicitly with kubectl delete, or let a GitOps controller (a way of running a cluster where a controller continuously makes the live state match Git, covered in the Argo/Flux lesson) reconcile deletions for you. Never assume re-applying a shrunken build cleans up after itself.
Quick check
01You drop a Service from your overlay's resources list and run kubectl apply -k overlays/prod again. What is the state of that Service in the cluster afterwards?
Incorrect — apply only pushes the objects the current build contains. It never walks the cluster looking for things your render no longer mentions.
Incorrect — A live object outside the build is simply outside the scope of the command, so there is nothing for kubectl to complain about and the apply succeeds.
Correct — Nothing in a plain apply prunes anything. The object outlives your change until an explicit kubectl delete or a controller that reconciles deletions catches up with it.
Incorrect — kubectl apply --prune is real, but it is off by default, tied to a label selector rather than to your kustomization, and easy to point at more than you meant.
02On your laptop kustomize version prints v5.4.3, while kubectl version --client reports Kustomize Version: v5.0.4. What does that gap mean for a deploy you need to be repeatable?
Incorrect — kubectl carries its own compiled-in copy and uses that for -k and kubectl kustomize. Installing the newer binary alongside it changes nothing about which code kubectl actually runs.
Incorrect — Kustomize runs entirely on your side of the wire. The API server receives finished YAML and has no idea a kustomization ever existed.
Incorrect — A version gap is usually much quieter than that. A newer field or flag can be ignored or handled differently rather than rejected, which is exactly what makes the drift worth pinning away.
Correct — Pin the standalone version in CI, render with it, then pipe the plain YAML to kubectl apply -f -. That takes whichever kubectl a given laptop happens to have out of the picture.
03A CI step runs kubectl diff -k overlays/prod for a change you were sure was a no-op, and the step exits with code 1. What should you conclude before merging it?
Incorrect — Exit codes above 1 are what diff reserves for the command itself falling over, so a connection problem would not land on 1.
Correct — Exit 1 is a finding, not a failure. Treat the step as a gate and go read the diff, because a change you called harmless would touch something running.
Incorrect — That reading has the codes backwards. A pipeline wired that way waves through every real change and blocks every clean one.
Incorrect — A restrictor block stops the build before any comparison happens, and it surfaces as an error message about a file outside the kustomization root rather than as exit 1.

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.

Related