CoursesKustomizeTransformers: images, names, labels

Transformers: images, names, labels

Cross-cutting edits declaratively.

Intermediate12 min · lesson 7 of 12

A patch is a scalpel. It reaches into one field of one resource and changes exactly that, which is what the patches lesson was about. A transformer is a stencil. You lay it over the whole build and it presses the same edit onto everything at once: retag every image, put a prefix on every name, stamp a label on every object. Kustomize ships a handful of these built in, switched on as plain top-level keys in kustomization.yaml (the little file that tells Kustomize what to assemble). The point is that a fleet-wide change lives in one readable, reviewable place instead of scattering into a dozen near-identical patches you then have to keep in sync by hand.

The base everything sits on

Every example below edits the same starting point: a base (the complete, valid set of manifests every environment starts from) with one Deployment (the Kubernetes object that runs and keeps your pods alive) and one ConfigMap (a small store of non-secret settings). YAML, by the way, is the plain-text format Kubernetes reads its objects from. The Deployment pulls its settings from the ConfigMap by name. Hold onto that link, because it is what makes reference rewriting easy to see later.

base/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
spec:
replicas: 2
selector:
matchLabels:
app: web
template:
metadata:
labels:
app: web
spec:
containers:
- name: nginx
image: nginx:1.25.3
envFrom:
- configMapRef:
name: app-config
base/configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
name: app-config
data:
LOG_LEVEL: info

Retag every image from one line

The images transformer rewrites container image references wherever they show up: Deployments, StatefulSets, CronJobs, init containers, all of them, matched by the original image name written in the base. This is how a production overlay pins a specific tag or swaps to a hardened internal registry (the server that stores your container images) without ever touching the base. Each entry matches by name, then sets a newName (a different repo or registry), a newTag, or a digest. Because the base never hard-codes an environment's tag, your pipeline can bump one line in the prod overlay and leave dev and staging exactly where they were.

overlays/prod/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../../base
images:
- name: nginx # match any container running 'nginx'
newName: registry.example.com/prod/nginx
newTag: "1.27.3"
terminal
kubectl kustomize overlays/prod
output
apiVersion: v1
data:
LOG_LEVEL: info
kind: ConfigMap
metadata:
name: app-config
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
spec:
replicas: 2
selector:
matchLabels:
app: web
template:
metadata:
labels:
app: web
spec:
containers:
- envFrom:
- configMapRef:
name: app-config
image: registry.example.com/prod/nginx:1.27.3
name: nginx

One thing about that output before we go on: kustomize prints every object's fields in alphabetical order, so data lands above kind, and envFrom sits above image and name. That reordering is cosmetic, and the base file you wrote never changes. Now the matching itself. It is exact on the image name, not fuzzy. An entry for nginx rewrites every container whose image is nginx at any tag, and ignores everything else, so one overlay can retag a dozen services at once by listing a dozen entries. If a name in your base matches no entry, that image passes through unchanged, which is usually what you want, but it also means a typo in the entry name silently does nothing. Render the output and read it before you trust it.

A tag like 1.27.3 is a sticky note on a box. Someone with write access to the registry can peel it off and stick it on a different box, and your next rollout would happily run whatever the tag now points at. That is a real supply-chain move: compromise the registry or the build, repoint a mutable tag, wait for a redeploy. A digest closes that door. It is a SHA-256 fingerprint (a hash computed from the image's exact bytes), so the name resolves to one specific image and nothing else. Pin production by digest and a swapped tag will not match.

overlays/prod/kustomization.yaml
images:
- name: nginx
newName: registry.example.com/prod/nginx
digest: sha256:5b8c...c2e # pin by content, not a movable tag
terminal
kubectl kustomize overlays/prod | grep image:
output
image: registry.example.com/prod/nginx@sha256:5b8c...c2e

Setting both a newTag and a digest is allowed. Kustomize does not complain; it emits nginx:1.27.3@sha256:... where the digest governs which image actually runs and the tag rides along as a human-readable label for whoever reads the manifest. Do not read that tag as a guarantee, though. It is a comment on the box, while the digest is the lock. If they ever disagree, the digest is what runs.

Rename the whole set, keep the wiring intact

namePrefix and nameSuffix rename every resource in the build, and here is the part people miss, they rewrite the references between those resources too. So a Deployment that names a ConfigMap, or a Service that fronts a workload, still points at the right thing after the rename. The namespace transformer sets metadata.namespace (which named partition of the cluster an object lives in) on every object in the build and repairs namespaced references the same way. Put together, these let two copies of one base live in a single cluster, a prod- copy and a staging- copy side by side, without a single hand-edited name. Generators (which build ConfigMaps and Secrets for you, covered earlier) get the prefix as well, and their content-hash suffix stacks on top of your nameSuffix, so the name still changes whenever the data does.

overlays/prod/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../../base
namePrefix: prod-
nameSuffix: -v2
namespace: production
terminal
kubectl kustomize overlays/prod
output
apiVersion: v1
data:
LOG_LEVEL: info
kind: ConfigMap
metadata:
name: prod-app-config-v2
namespace: production
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: prod-web-v2
namespace: production
spec:
replicas: 2
selector:
matchLabels:
app: web
template:
metadata:
labels:
app: web
spec:
containers:
- envFrom:
- configMapRef:
name: prod-app-config-v2
image: nginx:1.25.3
name: nginx

Read the output closely. The Deployment became prod-web-v2, the ConfigMap became prod-app-config-v2, both landed in the production namespace, and the Deployment's configMapRef was rewritten to the new ConfigMap name on its own. You changed three lines, and Kustomize kept the wiring correct. Notice what it did not touch: the selector (the label query that tells a Deployment which pods are its own) and the pod labels still say app: web. Names and labels are separate concerns, and namePrefix stays in its lane.

One caveat on namespace. It moves the objects and fixes references inside this build, but it will not chase a name you hard-coded as a string somewhere it cannot see, like a service URL written into a ConfigMap value. Kustomize rewrites structure it understands, not free text. If your app talks to app-config.default.svc by a literal string, that string is yours to keep in sync.

Where transformers run in a build
1kustomize build
reads base + overlay
2Load resources
base merged, generators run
3Run transformers
namespace, names, labels, images, replicas
4Emit YAML
cross-cutting edits baked in
Transformers run after resources and generators are loaded, so they see and can rewrite everything in the set, including the ConfigMaps a generator just produced.

Stamp labels and annotations across the fleet

labels and commonAnnotations write metadata onto every object in the build. This is the declarative way to satisfy a rule like every resource carries an owner and a part-of tag for the service graph. The modern labels field takes a list of pairs plus an includeSelectors flag; leave it false (the default) and it touches metadata only. The replicas transformer overrides the pod count for a named workload, which keeps a hard number out of the base so each overlay scales on its own. commonAnnotations behaves a little differently: it also copies onto the pod template, so restarts and rollouts carry the annotation down to the pods themselves.

overlays/prod/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../../base
labels:
- pairs:
app.kubernetes.io/part-of: checkout
env: prod
includeSelectors: false # default: metadata only, never selectors
commonAnnotations:
team: payments
replicas:
- name: web # target the workload by name
count: 5
terminal
kubectl kustomize overlays/prod # trimmed to the Deployment
output
apiVersion: apps/v1
kind: Deployment
metadata:
annotations:
team: payments
labels:
app.kubernetes.io/part-of: checkout
env: prod
name: web
spec:
replicas: 5
selector:
matchLabels:
app: web
template:
metadata:
annotations:
team: payments
labels:
app: web
spec:
containers:
- envFrom:
- configMapRef:
name: app-config
image: nginx:1.25.3
name: nginx

The Deployment now carries the two labels and the team annotation on its own metadata, the annotation reached the pod template, and replicas is 5. And notice: spec.selector.matchLabels is still app: web, and so are the pod template labels. That untouched selector is the whole point of includeSelectors: false. If you do want the labels on pods without touching selectors, that is what the separate includeTemplates flag is for. Keep those two flags distinct in your head, because selectors are the ones you can never safely change on a live object.

commonLabels writes into immutable selectors
commonLabels, and labels with includeSelectors: true, do not stop at decorating metadata. They merge their keys into a Deployment's spec.selector.matchLabels and a Service's spec.selector, and those selector fields are immutable on a live object (Kubernetes will not let you change them after creation). Add or change such a label on an already-deployed workload and the next apply fails with 'field is immutable', which forces a delete and recreate that tears down the ReplicaSet (the controller that keeps the right number of pods running) and causes real downtime. The safe pattern: define your selector labels exactly once in the base, where they never move, and use the newer labels field with includeSelectors: false for everything you stamp on afterward. Then you can add ownership, environment, and compliance labels across the whole fleet without ever rewriting a selector Kubernetes refuses to change.

Before you apply any of this, preview it. kubectl diff -k overlays/prod renders the overlay and diffs it against what is live, so you see the exact change first. If that diff shows spec.selector shifting on a Deployment that already exists, stop, because the apply will fail with 'field is immutable' and you will be reaching for a delete and recreate on a running service. The same habit catches an image that lost its digest or a namespace that moved by accident. Look before you apply.

Quick check
01A Deployment named web has been live for weeks. Someone adds commonLabels with team: payments to its prod overlay, builds it, and applies. Where does this go wrong?
Incorrect — A build never talks to the cluster. It renders text on your machine, so the objection can only come later, from the API server.
Incorrect — That is what the newer labels field with includeSelectors left at its default would do. commonLabels reaches further than metadata.
Correct — Selectors freeze at creation time, so the merged key makes the apply come back with 'field is immutable' instead of rolling out.
Incorrect — The teardown only happens if you go on to delete and recreate by hand. The apply itself is refused, so nothing moves at all.
02A prod images entry carries newName: registry.example.com/prod/nginx, newTag: "1.27.3" and a digest. The build runs clean and prints one image line ending in @sha256:5b8c...c2e. Read that line for a teammate.
Correct — A digest is computed from the image contents, so it can only ever resolve to one build. The version beside it is a convenience.
Incorrect — Read it the other way round. A tag can be moved onto a different image in the registry, which is the reason to pin by content.
Incorrect — There is no fallback step. The name resolves once, and when a digest is present that is the thing being resolved.
Incorrect — Kustomize is happy to emit name:tag@digest. Nothing stops you writing both, which is why you have to know which one wins.
03Your overlay sets namespace: production and namePrefix: prod-. The base ConfigMap holds a data value pointing the app at app-config.default.svc. What does kubectl kustomize overlays/prod show for that value?
Incorrect — Both transformers do chase references, but only ones they can parse as references. A string sitting in a value is not one of those.
Incorrect — Nothing here fails. The render finishes normally, which is exactly why a stale address inside a value is so easy to miss.
Incorrect — Data is carried through untouched. The namespace transformer writes metadata.namespace and fixes wiring, it does not prune content.
Correct — Keeping that address correct after a namespace move is your job, so search your ConfigMaps for hard-coded service names before you ship.

Make the digest rule something a machine enforces, not something you remember. Add one line to your continuous integration pipeline (the automated build-and-ship system): kubectl kustomize overlays/prod | grep -E 'image:', and fail the job if any line is missing @sha256:. From that point on an unpinned image cannot reach production without someone deliberately turning the check off.

Try this

Run kubectl kustomize 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: commonLabels writes into immutable selectors. 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