The previous lesson ended with an honest list of Helm's shortcomings: the templates stop being valid YAML, nindent and whitespace are a constant source of errors, and you have to learn the Go template engine before you can touch anything. Kustomize was born out of exactly that discontent and starts from the opposite premise: no templating at all.
In Kustomize, your manifests remain perfectly valid Kubernetes YAML. You can open them in your editor and have the schema validated, apply them with kubectl apply -f, and read them without decoding anything. What changes between environments is not expressed with variables but with overlays: small files declaring the differences from a common base.
In this lesson we will migrate Rutas Norte's k8s/ from 120 duplicated files to one base plus three overlays, we will cleanly solve the restart-on-configuration-change problem that in 03-03 we patched with a hand-written annotation, and we will finish by comparing Helm and Kustomize without favouritism.
Contents
- The template-free philosophy
- Kustomize inside kubectl and as a standalone binary
- The base + overlays model
kustomization.yamlfield by field- Patches: strategic merge and JSON Patch
- ConfigMap and Secret generators
- Components: reusable optional pieces
- Workflow: kustomize, diff and apply
- The complete Rutas Norte migration
- Helm versus Kustomize
- Combining Helm and Kustomize
- Common mistakes and tips
- Exercises
- Conclusion
- The template-free philosophy
Compare the two approaches to the same goal: giving bookings-api 1 replica in development and 4 in production.
With Helm, the manifest stops being Kubernetes YAML: replicas: {{ .Values.bookingsApi.replicaCount }}, image: {{ .Values.imageRegistry }}/bookings-api:{{ .Values.bookingsApi.image.tag }}. With Kustomize, the base is plain ordinary YAML (replicas: 1, image: registry.rutasnorte.example/bookings-api:2.4.0), applicable with kubectl apply -f, and the overlay declares the differences:
# k8s/environments/pro/kustomization.yaml
resources:
- ../../base
replicas:
- { name: bookings-api, count: 4 }
images:
- name: registry.rutasnorte.example/bookings-api
newTag: "2.4.0"
digest: sha256:9c1e4a7b3d2f8e6a...| Helm | Kustomize | |
|---|---|---|
| What a manifest is | A template that produces YAML | Valid YAML from the outset |
| How it is customised | By substituting variables before rendering | By transforming already-rendered YAML |
| What you have to learn | Go templates + Sprig + chart structure | One file: kustomization.yaml |
| Can it be applied without the tool | No | Yes, the base can |
| The editor validates the schema | No | Yes |
Kustomize works as a pipeline of transformations: it reads the base manifests, applies a series of declared operations (change the namespace, add labels, substitute the image, apply patches) and emits the resulting YAML.
flowchart LR
B["k8s/base/<br/>valid YAML"] --> T1[namespace] --> T2[labels] --> T3[images]
T3 --> T4[replicas] --> T5[patches] --> T6[generators] --> O["final YAML<br/>for the cluster"]
OV["overlay pro/<br/>kustomization.yaml"] -.declares.-> T1 & T3 & T5 & T6
style B fill:#e8f4ff
style O fill:#e8ffe8
That "transform existing YAML" nature has an elegant consequence: Kustomize understands Kubernetes types. When it merges two container lists it knows the correlation key is name; when it changes an image it knows where the image fields are in a Deployment, a StatefulSet, a CronJob or a DaemonSet. Helm, which only concatenates text, knows none of that.
- Kustomize inside kubectl and as a standalone binary
kubectl kustomize k8s/environments/pro # see the result without applying
kubectl apply -k k8s/environments/pro # apply
kubectl diff -k k8s/environments/pro # compare with the cluster
kubectl delete -k k8s/environments/proAn enormous advantage: there is nothing to install. Anybody with kubectl can deploy. Disadvantage: the built-in version lags behind the standalone one and cannot be updated without updating kubectl.
curl -s "https://raw.githubusercontent.com/kubernetes-sigs/kustomize/master/hack/install_kustomize.sh" | bash
kustomize build k8s/environments/pro | kubectl apply -f -| Aspect | kubectl -k |
kustomize |
|---|---|---|
| Installation | You already have it | A separate download |
| Version and new features | Months behind | Immediate |
External generators and helmCharts |
Limited | Complete |
Recommendation for Rutas Norte: kubectl -k for everyday interactive use; the binary with a pinned version in the ci-rutasnorte pipeline, so that everybody generates exactly the same YAML.
- The base + overlays model
k8s/
├── base/
│ ├── kustomization.yaml
│ ├── web-store/ (kustomization + deployment + service + hpa + ingress)
│ ├── bookings-api/ (+ servicemonitor + config/)
│ ├── bookings-postgres/ (an operator resource, 06-07)
│ ├── redis-cache/
│ ├── notifications-worker/ (+ a KEDA scaledobject, 09-04)
│ └── occupancy-reports/ (cronjob)
├── components/
│ ├── network-policies/ (NetworkPolicies, 04-06)
│ ├── high-availability/ (PDB + topology, 09-05)
│ └── observability/ (ServiceMonitors + rules, 07-03)
└── environments/
├── dev/ (kustomization.yaml + config.env + resources-patch.yaml)
├── pre/ (the same)
└── pro/ (the same + ingress-patch.yaml)Three concepts:
- Base: the common manifests. It must be deployable on its own and it holds the most conservative values.
- Overlay: a directory that references a base and declares the differences. One per environment.
- Component: a reusable piece that overlays optionally enable.
# k8s/base/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources: # Kustomize is recursive: each one has its own
- web-store
- bookings-api
- bookings-postgres
- redis-cache
- notifications-worker
- occupancy-reports
labels:
- includeSelectors: false # see section 4 for why
pairs:
app.kubernetes.io/part-of: rutas-norte
app.kubernetes.io/managed-by: kustomize# k8s/base/bookings-api/deployment.yaml
# Pure Kubernetes YAML. Conservative values, suited to development.
apiVersion: apps/v1
kind: Deployment
metadata:
name: bookings-api
labels: { app: bookings-api }
spec:
replicas: 1
selector:
matchLabels: { app: bookings-api }
strategy:
rollingUpdate: { maxSurge: 1, maxUnavailable: 0 }
template:
metadata:
labels: { app: bookings-api }
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "8080"
spec:
serviceAccountName: bookings-api
securityContext:
runAsNonRoot: true
runAsUser: 10001
seccompProfile: { type: RuntimeDefault }
containers:
- name: api
image: registry.rutasnorte.example/bookings-api:2.4.0
ports:
- { name: http, containerPort: 8080 }
envFrom:
- configMapRef: { name: bookings-api-config }
- secretRef: { name: bookings-api-credentials }
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities: { drop: ["ALL"] }
livenessProbe:
httpGet: { path: /health/live, port: http }
initialDelaySeconds: 15
readinessProbe:
httpGet: { path: /health/ready, port: http }
initialDelaySeconds: 5
resources:
requests: { cpu: 100m, memory: 128Mi }
limits: { memory: 256Mi }
volumeMounts: [{ name: tmp, mountPath: /tmp }]
volumes: [{ name: tmp, emptyDir: {} }]Note that this file is directly applicable with kubectl apply -f. You cannot do that with a Helm template: it is the main advantage of the approach.
And the development overlay is eleven lines:
# k8s/environments/dev/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: rutas-norte-dev
resources: [../../base]
labels:
- includeSelectors: false
pairs: { environment: dev }
images:
- { name: registry.rutasnorte.example/bookings-api, newTag: dev-abc123f }
- { name: registry.rutasnorte.example/web-store, newTag: dev-abc123f }
configMapGenerator:
- name: bookings-api-config
behavior: merge
envs: [config.env]
patches:
- path: resources-patch.yaml
kustomization.yaml field by field
kustomization.yaml field by fieldresources
Files, directories with their own kustomization.yaml, or remote URLs:
resources:
- deployment.yaml
- ../../base
- github.com/kubernetes-sigs/kustomize/examples/multibases?ref=v5.4.3About remote resources: always pin ?ref= to a tag or a commit. Without it you are pointing at the main branch and your deployment changes whenever somebody outside your team commits. It is the same principle as --version in Helm and immutable image tags (08-05).
namespace, namePrefix and nameSuffix
namespace: rutas-norte-pro sets metadata.namespace on every generated object and updates the cross-references (the namespace in a RoleBinding's subjects, for example). A single field eliminates the main duplication between environments. Cluster-scoped objects (ClusterRole, StorageClass, CRD) are unaffected: Kustomize knows.
namePrefix: rn- and nameSuffix: -pro rename the objects, and — very importantly — Kustomize updates the references: the HPA's scaleTargetRef, the StatefulSet's serviceName, the ConfigMap name in envFrom, the Ingress's backend.service.name. Rutas Norte does not use them, because it already separates environments by namespace and appending -pro to everything's name complicates the diagnostic commands; they are useful when you deploy two instances into the same namespace.
labels and why commonLabels is discouraged
This section deserves special attention because it is a real trap that breaks deployments.
# THE MODERN, CORRECT FORM
labels:
- includeSelectors: false # <-- THE KEY
pairs:
environment: pro
app.kubernetes.io/part-of: rutas-nortecommonLabels (the old form) adds the labels to metadata.labels and also to the Deployment's spec.selector.matchLabels, to the pod's spec.template.metadata.labels and to the Service's spec.selector. And there is the problem: a Deployment's spec.selector is immutable (02-03, 02-07). If the base is already deployed and you add a label, the next apply fails:
The Deployment "bookings-api" is invalid: spec.selector: Invalid value:
v1.LabelSelector{...}: field is immutableThe only way out is to delete the Deployment and recreate it, with an outage. In rutas-norte-pro, at eleven in the morning.
There is a second, subtler problem: if it adds environment: pro to the Service's selector, that Service will stop finding the pods that already existed without that label. Traffic to nowhere, with no visible error.
| Field | Touches the selectors | When to use it |
|---|---|---|
labels with includeSelectors: false |
No | By default, always |
labels with includeSelectors: true |
Yes | Only on a brand new deployment from scratch |
commonLabels |
Yes (equivalent to true) |
Discouraged; it exists for compatibility |
commonAnnotations |
N/A (they are not selectors) | No risk |
Rutas Norte's rule: the selector labels are defined once in the base (app: bookings-api) and are never touched again. Everything else is added with includeSelectors: false.
images
images:
- { name: registry.rutasnorte.example/bookings-api, newTag: "2.4.0" }
# When a digest is present, the digest is what rules (08-05)
- name: registry.rutasnorte.example/web-store
newTag: "3.1.2"
digest: sha256:9c1e4a7b3d2f8e6a5b4c3d2e1f0a9b8c7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f2a
# Change the whole registry (a replica in another region)
- name: registry.rutasnorte.example/notifications-worker
newName: registry-eu.rutasnorte.example/notifications-worker
newTag: "1.8.4"Kustomize looks for the image field in any type that has one, including initContainers. You do not have to tell it where to look. This is what the ci-rutasnorte pipeline modifies on every deployment:
cd k8s/environments/pre
kustomize edit set image registry.rutasnorte.example/bookings-api=registry.rutasnorte.example/bookings-api:rc-${GIT_SHA}kustomize edit modifies the kustomization.yaml on disk. Combined with an automatic commit, it is the piece that connects the image build with GitOps (10-05).
replicas
It saves writing a patch for something so common. But careful, picking up from 09-01: if bookings-api has an HPA, declaring replicas here is counterproductive. Every kubectl apply -k will return the Deployment to 4 replicas even though the HPA has it at 15 because of the May bank-holiday weekend load, with a capacity dip until the HPA reacts.
For components with an HPA, the correct solution is not to declare replicas anywhere (neither in the base nor in the overlay) and to let the HPA's minReplicas govern. Kubernetes does not require the field: it defaults to 1 and the HPA raises it immediately. The missing piece — making sure Argo CD does not report drift when the HPA changes that field — we will solve in 10-05 with ignoreDifferences.
- Patches: strategic merge and JSON Patch
The fields above cover the usual cases. For everything else, there are patches.
Strategic merge
You write a partial YAML with the same structure as the object. Kustomize merges it while understanding the semantics of Kubernetes types.
# k8s/environments/pro/resources-patch.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: bookings-api # the name and kind identify which object to patch
spec:
template:
spec:
containers:
# 'name' is the correlation key for the container list:
# Kustomize merges with the 'api' container, it does NOT replace the list.
- name: api
resources:
requests: { cpu: 250m, memory: 512Mi }
limits: { memory: 1Gi }
env:
- { name: LOG_LEVEL, value: warn }What makes strategic merge special is that Kustomize knows the schema: it knows that containers correlates by name, ports by containerPort, volumeMounts by mountPath. A patch that only mentions the api container leaves the sidecars untouched.
It can be written inline with patch: |- inside the kustomization.yaml, handy for small changes. And there are two special directives: $patch: replace replaces an entire list instead of merging it, and $patch: delete removes the matching element.
JSON Patch (RFC 6902)
When strategic merge is not enough — because you have to operate on list indexes, or on a CRD that Kustomize does not know — you use JSON Patch: a list of explicit operations.
patches:
- target: { kind: Deployment, name: bookings-api }
patch: |-
# '-' means "append to the end of the list"
- op: add
path: /spec/template/spec/containers/0/env/-
value: { name: REGION, value: eu-west }
- op: replace
path: /spec/template/spec/containers/0/resources/limits/memory
value: 2Gi
- op: remove
path: /spec/template/spec/containers/0/livenessProbe/initialDelaySeconds
# '/' and '~' in a key are escaped as ~1 and ~0
- op: add
path: /spec/template/metadata/annotations/rutasnorte.example~1revision
value: "7"Details you need to know: the paths start at the root of the object and list indexes are numeric (/spec/template/spec/containers/0/); replace fails if the path does not exist, whereas add creates or replaces it, so when in doubt use add.
The target field: who the patch applies to
Here is the system's real power. A patch can be aimed at many objects at once.
patches:
- target: { kind: Deployment, name: bookings-api }
path: resources-patch.yaml
# To ALL Deployments (the name accepts a regular expression)
- target: { kind: Deployment, name: ".*" }
patch: |-
- op: add
path: /spec/template/metadata/annotations/rutasnorte.example~1reviewed
value: "2026-08-05"
# By label
- target: { labelSelector: "app.kubernetes.io/part-of=rutas-norte", kind: Deployment }
patch: |-
- op: add
path: /spec/template/spec/priorityClassName
value: rutas-norte-high
# By API group and version: needed with CRDs
- target: { group: keda.sh, version: v1alpha1, kind: ScaledObject, name: notifications-worker }
patch: |-
- op: replace
path: /spec/maxReplicaCount
value: 30The selectors available in target are kind, name (exact or regex), namespace, group, version, labelSelector and annotationSelector.
patchesStrategicMerge and patchesJson6902 are deprecated
You will see plenty of old code using those two fields. The unified patches field replaces both: it automatically detects whether the content is a strategic merge or a JSON Patch, and it accepts target with selectors in both cases, which the old ones did not allow.
| Deprecated field | Replacement | Advantage of the new one |
|---|---|---|
patchesStrategicMerge |
patches with path |
Accepts target with selectors |
patchesJson6902 |
patches with target |
Unified syntax, inline patch |
commonLabels |
labels with includeSelectors |
Control over the immutable selectors |
bases |
resources |
A single concept |
vars |
replacements |
More powerful and more predictable |
kustomize edit fix migrates the deprecated fields automatically.
- ConfigMap and Secret generators
This is Kustomize's most elegant feature, and it cleanly solves a problem we fixed by hand in 03-03.
configMapGenerator:
# From literals
- name: bookings-api-config
literals: [LOG_LEVEL=info, BOOKING_TTL_MINUTES=15, CACHE_HOST=redis-cache]
# From files: each file becomes a key holding its contents
- name: bookings-api-templates
files:
- config/app.properties
- email-template=config/booking-email.html # rename the key
# From a variables file: one key per line
- name: bookings-api-environment
envs: [config/production.env]The hash suffix: the crown jewel
Kustomize appends a hash of the contents to the name. And — this is the important bit — it automatically updates every reference, so the Deployment ends up with configMapRef: { name: bookings-api-config-9t2hmf6b4d }.
Think about what that implies. When you change LOG_LEVEL from info to warn: the contents change → the hash changes → the reference in the Deployment changes → the pod template changes → the Deployment rolls out automatically.
flowchart LR
A["You change<br/>config.env"] --> B["New hash:<br/>...-c7d4k9m2t8"] --> C["The reference in the<br/>Deployment changes"]
C --> D["The pod template<br/>changes"] --> E["Automatic rollout<br/>with the new config"]
style E fill:#e8ffe8
This solves, natively and without tricks, the problem from 03-03. There we explained that an updated ConfigMap does not restart the pods and that the workaround was adding an annotation with the hash by hand. With Kustomize there is no workaround: it is the default behaviour. And compared with Helm (10-03), where you had to write checksum/config: {{ include ... | sha256sum }} in every template, here you write nothing.
An additional advantage: rollback genuinely works. Since every version of the configuration is a distinct object with a distinct name, a kubectl rollout undo returns the pod to the previous reference, and the old ConfigMap still exists as long as some ReplicaSet references it. With a fixed-name ConfigMap, the rollback would restore the old code but with the new configuration: the worst of both worlds.
Disabling the hash
Also per generator, with options: { disableNameSuffixHash: true } inside a specific entry.
When should you disable it? Only when something external references the ConfigMap by a fixed name that Kustomize cannot update: a CRD from an operator that Kustomize cannot interpret, a pod that deliberately re-reads the ConfigMap at runtime, or one shared between applications managed by different systems. Firm advice: do not disable it without a concrete reason. The hash is Kustomize's best feature.
behavior: extending a generator from the base
If the base defines bookings-api-config with LOG_LEVEL=info, BOOKING_TTL_MINUTES=15 and CACHE_HOST=redis-cache, and the production overlay declares:
configMapGenerator:
- name: bookings-api-config
behavior: merge # <-- merges with the base's
envs: [config.env] # LOG_LEVEL=warn, GATEWAY_URL=...The result combines the three inherited keys with LOG_LEVEL overridden and GATEWAY_URL added.
behavior |
Effect |
|---|---|
| (unspecified) | Creates a new one; fails if one already exists with that name |
merge |
Merges with the base's, overriding the matching keys |
replace |
Completely replaces the base's |
secretGenerator
The same mechanics, with automatic base64 encoding and support for type: kubernetes.io/tls for certificates from files.
Critical warning:
secretGeneratorencrypts nothing. Base64 is not encryption (03-02). If you put thebookings-postgrespassword in aliteralsentry or in a file in the repository, that password is in the clear in Git for ever, even if you delete it later.
The correct approach at Rutas Norte, picking up what we announced in 03-02, is to reference a Secret encrypted with SOPS or Sealed Secrets, or better still an External Secrets Operator resource that fetches the value from Vault:
# k8s/environments/pro/external-secret.yaml
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata: { name: bookings-api-credentials }
spec:
refreshInterval: 1h
secretStoreRef: { name: vault-rutasnorte, kind: ClusterSecretStore }
target: { name: bookings-api-credentials, creationPolicy: Owner }
data:
- secretKey: DB_PASSWORD
remoteRef: { key: rutas-norte/pro/postgres, property: password }
- secretKey: GATEWAY_TOKEN
remoteRef: { key: rutas-norte/pro/payments, property: token }That file is harmless: it only says where the secret is, not what it is. We will come back to it in 10-05.
- Components: reusable optional pieces
A component is like an overlay, but designed to be included by several of them. It solves the "I want this in pre and in pro, but not in dev" case. Rutas Norte has three clear candidates: the NetworkPolicies (04-06), high availability (09-05) and observability (07-03). In development they get in the way; in the other two environments they are mandatory.
# k8s/components/network-policies/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1alpha1
kind: Component # <-- Component, NOT Kustomization
resources: [netpol.yaml]# k8s/components/network-policies/netpol.yaml (excerpt)
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: postgres-api-only }
spec:
podSelector:
matchLabels: { app: bookings-postgres }
policyTypes: [Ingress]
ingress:
- from:
- podSelector:
matchLabels: { app: bookings-api }
ports: [{ protocol: TCP, port: 5432 }]A component can also carry patches, not just resources:
# k8s/components/high-availability/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1alpha1
kind: Component
resources: [pdb.yaml]
patches:
- target: { kind: Deployment, labelSelector: "app.kubernetes.io/part-of=rutas-norte" }
path: topology-patch.yaml# topology-patch.yaml — the 'target' rules, so the name is ignored
apiVersion: apps/v1
kind: Deployment
metadata: { name: DOES-NOT-MATTER }
spec:
template:
spec:
topologySpreadConstraints:
- maxSkew: 1
topologyKey: topology.kubernetes.io/zone
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels: { app.kubernetes.io/part-of: rutas-norte }And the overlays enable them: dev carries none, pre carries network-policies and observability, and pro adds high-availability on top.
| Base | Overlay | Component | |
|---|---|---|---|
kind / apiVersion |
Kustomization / v1beta1 |
Kustomization / v1beta1 |
Component / v1alpha1 |
| Used from | resources |
Applied directly | components |
| How many times | Once per overlay | Once | Several overlays |
| Purpose | The common part | A specific environment | An optional capability |
Components are applied in the order they appear, after the resources. If two of them patch the same field, the last one wins.
- Workflow: kustomize, diff and apply
# 1. LOOK at the result before anything else. Always the first step.
kubectl kustomize k8s/environments/pro > /tmp/pro-generated.yaml
# 2. Validate against the server (real schema + admission webhooks)
kubectl kustomize k8s/environments/pro | kubectl apply --dry-run=server -f -
# 3. COMPARE with what is in the cluster. The decisive step.
kubectl diff -k k8s/environments/pro--- LIVE
+++ MERGED
envFrom:
- configMapRef:
- name: bookings-api-config-9t2hmf6b4d
+ name: bookings-api-config-c7d4k9m2t8
resources:
limits:
- memory: 1Gi
+ memory: 2GiThat output tells you exactly what is going to happen: the configuration hash changes (there will be a rollout) and the memory limit goes up. It is the equivalent of helm diff from the previous lesson, but built into kubectl, with no plugins.
# 4. Apply and verify
kubectl apply -k k8s/environments/pro
kubectl rollout status deploy/bookings-api -n rutas-norte-pro --timeout=300sPruning and validation in the pipeline
A real problem: if you delete a manifest from the repository, kubectl apply -k does not delete the object from the cluster. It is left orphaned. There is --prune with --applyset and --prune-allowlist, but it is cumbersome and you have to enumerate the kinds. It is another of the problems GitOps (10-05) solves natively: Argo CD and Flux know which objects belong to them and prune them on their own.
#!/usr/bin/env bash
# ci/validate-manifests.sh — free and cluster-free, ideal for every PR
set -euo pipefail
for environment in dev pre pro; do
kustomize build "k8s/environments/${environment}" > "/tmp/${environment}.yaml" # valid YAML?
kubeconform -strict -summary "/tmp/${environment}.yaml" # schema?
kyverno apply policies/ --resource "/tmp/${environment}.yaml" # policies (08-03)?
done
- The complete Rutas Norte migration
Before and after
BEFORE AFTER
k8s/ k8s/
├── dev/ 38 files ├── base/ 26 files
├── pre/ 38 files ├── components/ 7 files
└── pro/ 38 files └── environments/ 10 files
= 114 files = 43 filesAnd what matters is not the number of files, but that every line of configuration exists exactly once.
The process, step by step
Step 1: choose the base. The simplest environment, normally dev. Copy its manifests to k8s/base/, stripping the namespace (the overlay will set it) with yq -i 'del(.metadata.namespace)' k8s/base/**/*.yaml.
Step 2: write the base's kustomization.yaml files (section 3).
Step 3: work out each environment's real delta.
< namespace: rutas-norte-dev > namespace: rutas-norte-pro
< replicas: 1 > replicas: 4
< image: ...bookings-api:dev-abc > image: ...bookings-api:2.4.0
< requests: {cpu: 50m, mem: 64Mi} > requests: {cpu: 250m, mem: 512Mi}Four differences. Three are solved with kustomization.yaml fields (namespace, replicas, images) and only one needs a patch (resources).
Step 4: write the overlays.
# k8s/environments/pro/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: rutas-norte-pro
resources:
- ../../base
- external-secret.yaml
components:
- ../../components/network-policies
- ../../components/observability
- ../../components/high-availability
labels:
- includeSelectors: false
pairs: { environment: pro }
commonAnnotations:
rutasnorte.example/team: platform
rutasnorte.example/criticality: high
images:
- name: registry.rutasnorte.example/bookings-api
newTag: "2.4.0"
digest: sha256:9c1e4a7b3d2f8e6a5b4c3d2e1f0a9b8c7d6e5f4a3b2c1d0e9f8a7b6c5d4e3f2a
- { name: registry.rutasnorte.example/web-store, newTag: "3.1.2" }
# web-store and bookings-api carry NO 'replicas': the HPA governs them (09-01)
replicas:
- { name: occupancy-reports-launcher, count: 1 }
configMapGenerator:
- name: bookings-api-config
behavior: merge
envs: [config.env]
patches:
- path: resources-patch.yaml
- path: ingress-patch.yaml # a real Let's Encrypt certificate (04-05)
- target: { kind: HorizontalPodAutoscaler, name: bookings-api }
patch: |-
- { op: replace, path: /spec/minReplicas, value: 4 }
- { op: replace, path: /spec/maxReplicas, value: 20 }Step 5: verify that the migration changes nothing. This is the step that builds confidence.
An empty diff means you can run kubectl apply -k and absolutely nothing will happen. The migration is a risk-free operation.
Step 6: delete the old directories with git rm -r k8s/dev k8s/pre k8s/pro and update the pipeline.
The result in numbers
| Metric | Before | After |
|---|---|---|
| YAML files / total lines | 114 / ~4,800 | 43 / ~1,700 |
Places to change bookings-api's memory limit |
3 | 1 |
| Risk of an environment drifting out of sync | High | Zero by construction |
| Restart when the configuration changes | Manual (annotation) | Automatic (hash) |
| Being able to answer "what is in pro?" | No | kubectl kustomize k8s/environments/pro |
- Helm versus Kustomize
| Criterion | Helm | Kustomize |
|---|---|---|
| Learning curve | High: Go templates, Sprig, nindent |
Low: one declarative file |
| Readability of the sources | Low: {{- if }}, {{ toYaml | nindent }} |
High: the manifests are valid YAML |
| Readability of the customisation | High: values.yaml is flat and explicit |
Medium: you have to follow the patch chain |
| Installation | A separate binary | Built into kubectl |
| Distribution to third parties | Excellent: versioned .tgz, OCI |
Poor: you share a Git repository |
| Ecosystem | Enormous | Sparse: hardly any published bases |
| State in the cluster | Yes: release Secrets | No: it only generates YAML |
| Built-in rollback and clean-up | Yes: rollback, uninstall |
No: pruning is manual and cumbersome |
| Hooks and ordering | Yes | No (Argo CD's waves cover it, 10-05) |
| Conditionals and loops | Yes: arbitrary logic | No: components is the closest thing |
| Rollout when the configuration changes | Manual (checksum/config) |
Automatic (hash in the name) |
| Validation with standard tooling | Not until it is rendered | Yes |
| Changes the author did not anticipate | Hard: you have to fork the chart | Easy: a patch reaches any field |
The industry's practical rule
flowchart TB
Q{"Whose software<br/>is this?"}
Q -->|"Third-party:<br/>cert-manager, Prometheus,<br/>ingress-nginx, KEDA"| H["**Helm**<br/>consume the official chart<br/>with a versioned values.yaml"]
Q -->|"Ours:<br/>web-store, bookings-api,<br/>notifications-worker"| K["**Kustomize**<br/>base + overlays"]
H --> G["Both versioned in Git<br/>and deployed by GitOps (10-05)"]
K --> G
style H fill:#fff4e8
style K fill:#e8f4ff
Helm to consume, Kustomize to produce. It is Rutas Norte's decision and that of most mature teams: nobody wants to maintain kube-prometheus-stack's 60 objects by hand, and nobody wants to turn their own Deployment into an unreadable template. The exception: if you distribute your software to customers who install it in their own clusters, you need a versioned, configurable package, and there Helm has no rival.
- Combining Helm and Kustomize
There are two ways to use the two tools together, and they serve the same purpose: modifying a third-party chart in a field its author did not parameterise.
Approach A: helm template and kustomize the output
helm template monitoring prometheus-community/kube-prometheus-stack \
--version 65.1.1 --namespace monitoring \
-f platform/values-pro.yaml --include-crds \
> platform/generated/kube-prometheus-stack.yaml# platform/generated/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: monitoring
resources: [kube-prometheus-stack.yaml]
patches:
# Whatever the chart does NOT let you configure, we patch here
- target: { kind: Deployment, name: monitoring-grafana }
patch: |-
- op: add
path: /spec/template/spec/containers/0/env/-
value: { name: GF_FEATURE_TOGGLES_ENABLE, value: "traceToMetrics" }| Advantage | Drawback |
|---|---|
| The generated YAML goes into Git: you know exactly what is deployed | You have to regenerate by hand when the chart is updated |
| You can patch any field | The generated file is enormous and the diffs are noisy |
| Reviewable in a change request, reproducible bit for bit | You lose helm rollback and helm history |
Approach B: the helmCharts generator
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: monitoring
helmCharts:
- name: kube-prometheus-stack
repo: https://prometheus-community.github.io/helm-charts
version: 65.1.1
releaseName: monitoring
valuesFile: values-pro.yaml
includeCRDs: true
patches:
- target: { kind: Deployment, name: monitoring-grafana }
patch: |-
- op: add
path: /spec/template/spec/containers/0/env/-
value: { name: GF_FEATURE_TOGGLES_ENABLE, value: "traceToMetrics" }# Requires explicit permission because it runs an external binary
kustomize build --enable-helm platform/monitoringA single file declares everything and updating means changing the version number, but it needs --enable-helm and a helm installation, the result is not in Git, and support is uneven (kubectl -k does not handle it well; Argo CD and Flux need extra configuration).
Recommendation for Rutas Norte: approach A. Having the generated YAML in Git is exactly what makes GitOps work well: anybody can read the repository and know what is deployed.
Common Mistakes and Tips
1. Using commonLabels (or labels with includeSelectors: true) on something already deployed. It modifies spec.selector, which is immutable, and the apply fails. The only way out is delete and recreate, with an outage. Always use includeSelectors: false.
2. Declaring replicas on a component with an HPA. Every apply undoes the autoscaling until the HPA reacts. For anything with an HPA, do not declare replicas anywhere.
3. Secrets in secretGenerator with literals. Base64 is not encryption. If you write the bookings-postgres password into a file in the repository, it is in the clear in the Git history for ever. Use SOPS, Sealed Secrets or External Secrets Operator.
4. Setting disableNameSuffixHash without needing to. You lose the automatic rollout on configuration changes, which is Kustomize's best feature.
5. Remote resources without ?ref=. Your deployment changes when a stranger makes a commit. Always pin a tag or a commit.
6. Putting too much in the base. If the base contains things half the environments have to patch out, it is badly designed. The base is the common minimum; the optional goes in components.
7. Very deep overlay chains. Base → common → region → environment → customer is technically possible and humanly impractical: nobody knows where a value comes from. Two levels, three at most.
8. A patch that does not apply and nobody notices. If the target matches nothing, Kustomize does not always warn you. Always verify with kubectl kustomize that the change appears in the output.
9. replace in a JSON Patch on a path that does not exist. It fails with "missing value". Use add, which creates or replaces.
10. Forgetting to escape / in JSON Patch keys. rutasnorte.example/revision is written rutasnorte.example~1revision.
11. Applying without kubectl diff -k first. It is free, it takes two seconds and it shows you exactly what is going to change. In production it should be mandatory.
12. Assuming that lists always merge. In strategic merge, lists with a known correlation key (containers by name) merge; those without one (args, command) are replaced wholesale.
13. Editing objects by hand with kubectl edit and forgetting about it. Kustomize does not reconcile: it only acts when somebody runs apply. The next deployment will overwrite the manual change without warning. This is exactly the problem GitOps solves.
Exercises
Exercise 1: a complete pre-production overlay
Starting from the base described in the lesson, write k8s/environments/pre/kustomization.yaml so that it: uses the rutas-norte-pre namespace, labels everything with environment: pre without touching the selectors, pins the images to rc-2.4.0, enables the network-policies and observability components, merges a ConfigMap with LOG_LEVEL=info and a test gateway URL, and applies a patch raising the resources to half of production's. Verify the result without applying anything.
Exercise 2: a label-targeted JSON patch
Write a patch that adds to all Deployments labelled app.kubernetes.io/part-of: rutas-norte an initContainer waiting for bookings-postgres to be reachable before starting (06-04), without modifying any file in the base. Explain why you need JSON Patch and not strategic merge.
Exercise 3: demonstrating the automatic hash-driven rollout
With the dev overlay deployed, demonstrate in three steps that changing one line of config.env triggers an automatic rollout: capture the ConfigMap name and the Deployment's generation beforehand, change the value, apply, and compare. Explain why this would not happen with a fixed-name ConfigMap.
Solutions
Solution 1
# k8s/environments/pre/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: rutas-norte-pre
resources: [../../base]
components:
- ../../components/network-policies
- ../../components/observability
labels:
- includeSelectors: false # KEY: do not touch the immutable selectors
pairs: { environment: pre }
images:
- { name: registry.rutasnorte.example/bookings-api, newTag: rc-2.4.0 }
- { name: registry.rutasnorte.example/web-store, newTag: rc-3.1.2 }
configMapGenerator:
- name: bookings-api-config
behavior: merge
envs: [config.env]
patches:
- path: resources-patch.yaml# k8s/environments/pre/config.env
LOG_LEVEL=info
GATEWAY_URL=https://pagos-sandbox.proveedorexterno.example/v2# k8s/environments/pre/resources-patch.yaml
apiVersion: apps/v1
kind: Deployment
metadata: { name: bookings-api }
spec:
template:
spec:
containers:
- name: api
resources:
requests: { cpu: 125m, memory: 256Mi }
limits: { memory: 512Mi }kubectl kustomize k8s/environments/pre | grep -E 'namespace:|environment:|image:|memory:'
kubectl kustomize k8s/environments/pre | kubectl apply --dry-run=server -f -
kubectl diff -k k8s/environments/preSolution 2
patches:
- target:
kind: Deployment
labelSelector: "app.kubernetes.io/part-of=rutas-norte"
patch: |-
- op: add
path: /spec/template/spec/initContainers
value: []
- op: add
path: /spec/template/spec/initContainers/-
value:
name: wait-for-db
image: postgres:16.4
command: ["sh","-c","until pg_isready -h bookings-postgres -p 5432; do sleep 2; done"]
securityContext:
allowPrivilegeEscalation: false
runAsNonRoot: true
runAsUser: 10001
capabilities: { drop: ["ALL"] }
resources:
requests: { cpu: 10m, memory: 32Mi }
limits: { memory: 64Mi }Note: the first operation would empty an already-existing initContainers list. If some Deployments already have init containers that must be preserved, the robust solution is to split it into two patches with different targets.
Why JSON Patch: strategic merge demands one file per object with its exact metadata.name, so it cannot be aimed at "everything matching a label". JSON Patch combined with labelSelector in target reaches N objects with a single declaration, and /- lets you append without knowing how many elements there were.
Solution 3
# --- BEFORE ---
kubectl get deploy bookings-api -n rutas-norte-dev \
-o jsonpath='{.metadata.generation}{"\n"}{.spec.template.spec.containers[0].envFrom[0].configMapRef.name}{"\n"}'
# 4
# bookings-api-config-9t2hmf6b4d
# --- THE CHANGE ---
sed -i 's/LOG_LEVEL=debug/LOG_LEVEL=info/' k8s/environments/dev/config.env
kubectl diff -k k8s/environments/dev # the ConfigMap name change is visible
kubectl apply -k k8s/environments/dev
# --- AFTER --- -> 5 and bookings-api-config-c7d4k9m2t8
kubectl rollout status deploy/bookings-api -n rutas-norte-devExplanation: the hash is part of the ConfigMap's name, and that name appears inside the Deployment's spec.template. Changing the pod template makes the controller create a new ReplicaSet and run the rolling update (02-03).
Why it would not happen with a fixed name: spec.template would remain byte-for-byte identical, metadata.generation would not change, and there would be no new ReplicaSet. The pods would carry on with the old variables until somebody ran kubectl rollout restart by hand. That is precisely the checksum/config annotation workaround from 03-03, which is not needed here.
Conclusion
Kustomize has reduced Rutas Norte's k8s/ from 114 duplicated files to 43, with every value defined exactly once. The essentials:
- No templating: the manifests in
k8s/base/remain valid Kubernetes YAML, applicable withkubectl apply -fand checkable by your editor. What changes between environments is declared as transformations in the overlay. - It ships with kubectl (
apply -k,diff -k,kubectl kustomize), although the standalone binary is more up to date and is the one worth pinning in the pipeline. - The base + overlays + components model covers the three real axes: the common part, what is specific to each environment, and the optional capabilities only some environments want.
namespace,images,replicasandlabelsresolve most differences without writing a patch. Andlabelsmust carryincludeSelectors: false:commonLabelstouches the immutable selectors and breaks running deployments.- For everything else there are
patches, with strategic merge or JSON Patch, and atargetthat can be aimed by kind, name, label or annotation.patchesStrategicMergeandpatchesJson6902are deprecated. - The generators with their hash suffix are the jewel: changing one line of configuration changes the ConfigMap's name, changes the pod template and triggers an automatic rollout. It natively solves what we fixed by hand in 03-03, and it also makes the rollback restore code and configuration together.
- And in the honest comparison with Helm, the rule is clear: Helm for consuming third-party software, Kustomize for your own applications, with two ways of combining them when you need to patch somebody else's chart.
But one problem remains that neither Helm nor Kustomize solves, and it has come up in both lessons. Both tools only act when somebody runs a command. If somebody runs kubectl edit in production, neither finds out. If the laptop of whoever deploys breaks, nobody knows how to deploy. If the ci-rutasnorte pipeline needs cluster administrator credentials to run kubectl apply, we have a serious security problem that contradicts the least-privilege RBAC we defined in 08-01.
In the next lesson, GitOps with Argo CD and Flux, we take the final step: an agent that lives inside the cluster, pulls from Git continuously and reconciles reality with what is declared. We will look at Argo CD's Application resource, ApplicationSets for deploying the three environments from a single definition, the Flux equivalent, the three solutions to the problem of secrets in a repository, and we will finally close the conflict between the HPA and the replicas field that we flagged back in 09-01.
Kubernetes Course
Module 1: Introduction to Kubernetes
- What Is Kubernetes?
- Kubernetes Architecture
- Key Concepts and Terminology
- Setting Up a Kubernetes Cluster
- The Kubernetes CLI: kubectl
- Objects, YAML Manifests and the Declarative Model
- The Course Project: the Rutas Norte Platform
Module 2: Core Kubernetes Components
- Pods
- ReplicaSets
- Deployments
- Updates, Rollbacks and Deployment Strategies
- Services
- Namespaces
- Labels, Selectors and Annotations
Module 3: Configuration and Secret Management
- ConfigMaps
- Secrets
- Environment Variables
- Resource Quotas and Limits
- LimitRanges and Quality of Service (QoS) Classes
- ServiceAccounts and API Access from Pods
Module 4: Networking in Kubernetes
- Cluster Networking
- Service Types
- Internal DNS and Service Discovery
- Ingress Controllers
- TLS and Certificate Management with cert-manager
- Network Policies
Module 5: Storage in Kubernetes
- Volumes
- Persistent Volumes
- Persistent Volume Claims
- Storage Classes
- Dynamic Provisioning, Expansion and Snapshots
- Backup and Restore of Persistent Data
Module 6: Advanced Kubernetes Concepts
- StatefulSets
- DaemonSets
- Jobs and CronJobs
- Init Containers, Sidecars and Multi-Container Patterns
- Scheduling: Affinity, Taints and Tolerations
- Custom Resource Definitions (CRDs)
- Operators and the Controller Pattern
Module 7: Monitoring and Logging
- Health Checks and Probes
- Metrics Server and kubectl top
- Monitoring with Prometheus
- Visualization and Alerting with Grafana and Alertmanager
- Centralized Logging with Elasticsearch, Fluentd and Kibana (EFK)
- Application Debugging and Cluster Events
Module 8: Kubernetes Security
- Role-Based Access Control (RBAC)
- Security Contexts and Container Hardening
- Pod Security Policies and Pod Security Standards
- Network Security
- Image Security
- Auditing, Scanning and Vulnerability Management
Module 9: Scaling and Performance
- Horizontal Pod Autoscaling
- Vertical Pod Autoscaling
- Cluster Autoscaling
- Event-Driven and Custom-Metric Scaling with KEDA
- High Availability: PodDisruptionBudgets and Topology
- Performance Tuning
Module 10: Kubernetes Ecosystem and Tooling
- Minikube and Local Environments with kind
- Kubeadm
- Helm
- Kustomize
- GitOps with Argo CD and Flux
- Managed Kubernetes: EKS, AKS and GKE
Module 11: Case Studies and Real-World Applications
- Deploying a Web Application
- Running Stateful Applications
- CI/CD with Kubernetes
- Deployment Strategies: Blue-Green and Canary
- Multi-Cluster Management
- Production Operations: Incidents, Runbooks and Costs
