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

  1. The template-free philosophy
  2. Kustomize inside kubectl and as a standalone binary
  3. The base + overlays model
  4. kustomization.yaml field by field
  5. Patches: strategic merge and JSON Patch
  6. ConfigMap and Secret generators
  7. Components: reusable optional pieces
  8. Workflow: kustomize, diff and apply
  9. The complete Rutas Norte migration
  10. Helm versus Kustomize
  11. Combining Helm and Kustomize
  12. Common mistakes and tips
  13. Exercises
  14. Conclusion

  1. 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.

  1. 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/pro

An 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.

  1. 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

  1. kustomization.yaml field by field

resources

Files, directories with their own kustomization.yaml, or remote URLs:

resources:
  - deployment.yaml
  - ../../base
  - github.com/kubernetes-sigs/kustomize/examples/multibases?ref=v5.4.3

About 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-norte

commonLabels (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 immutable

The 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

replicas:
  - { name: occupancy-reports-launcher, count: 1 }

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.

  1. 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: 30

The 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.

  1. 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

kubectl kustomize k8s/environments/pro | grep -A3 'kind: ConfigMap'
kind: ConfigMap
metadata:
  name: bookings-api-config-9t2hmf6b4d
  namespace: rutas-norte-pro

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

generatorOptions:
  disableNameSuffixHash: true
  labels: { generated-by: kustomize }

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: secretGenerator encrypts nothing. Base64 is not encryption (03-02). If you put the bookings-postgres password in a literals entry 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.

  1. 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.

  1. 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: 2Gi

That 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=300s

Pruning 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

  1. 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 files

And 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.

diff k8s/dev/bookings-api-deployment.yaml k8s/pro/bookings-api-deployment.yaml
<   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.

kubectl diff -k k8s/environments/pro

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

  1. 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.

  1. 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/monitoring

A 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/pre

Solution 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-dev

Explanation: 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 with kubectl apply -f and 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, replicas and labels resolve most differences without writing a patch. And labels must carry includeSelectors: false: commonLabels touches the immutable selectors and breaks running deployments.
  • For everything else there are patches, with strategic merge or JSON Patch, and a target that can be aimed by kind, name, label or annotation. patchesStrategicMerge and patchesJson6902 are 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

Module 2: Core Kubernetes Components

Module 3: Configuration and Secret Management

Module 4: Networking in Kubernetes

Module 5: Storage in Kubernetes

Module 6: Advanced Kubernetes Concepts

Module 7: Monitoring and Logging

Module 8: Kubernetes Security

Module 9: Scaling and Performance

Module 10: Kubernetes Ecosystem and Tooling

Module 11: Case Studies and Real-World Applications

Module 12: Preparing for Kubernetes Certification

© Copyright 2026. All rights reserved