Skip to main content
ship it and sleep

Kustomize Overlays, Strategic Merge Patches, and When Helm Is Overkill

4 min read Chapter 39 of 66

Kustomize Overlays, Strategic Merge Patches, and When Helm Is Overkill

The Failure

The team used Helm to template a simple Deployment with 3 environment-specific values: replica count, image tag, and resource limits. The Helm chart had 150 lines of Go templates for a 40-line Deployment. When a new developer asked “what does this service deploy?”, they had to mentally render the templates to understand the output.

Kustomize would have been 40 lines of base YAML (readable Kubernetes manifests) plus 10 lines of patches per environment. No template language. No mental rendering. The base YAML is a valid Kubernetes manifest that can be applied directly.

The Mechanism

Patch Types

Patch TypeUse CaseSyntax
Strategic mergeAdd/modify fields in existing resourcesStandard YAML, merged by name
JSON patchPrecise field operations (add, remove, replace)JSON array of operations
Inline patchSimple patches without separate filesInline in kustomization.yaml

Strategic merge patches are the default. They merge by matching resource name and field paths. JSON patches are more precise when you need to add items to arrays or remove specific fields.

The Implementation

Base Resources

# apps/checkout-service/base/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: checkout-service
spec:
  replicas: 1
  selector:
    matchLabels:
      app: checkout-service
  template:
    metadata:
      labels:
        app: checkout-service
    spec:
      containers:
        - name: checkout
          image: ghcr.io/acme/checkout-service:latest
          ports:
            - containerPort: 8080
          resources:
            requests:
              cpu: 100m
              memory: 256Mi
            limits:
              cpu: 500m
              memory: 512Mi
          envFrom:
            - configMapRef:
                name: checkout-config

Strategic Merge Patch (Production)

# apps/checkout-service/overlays/production/patch-resources.yaml
# HARDENED: Production resource scaling
apiVersion: apps/v1
kind: Deployment
metadata:
  name: checkout-service
spec:
  replicas: 3
  template:
    spec:
      containers:
        - name: checkout
          resources:
            requests:
              cpu: 250m
              memory: 512Mi
            limits:
              cpu: 1000m
              memory: 1Gi

JSON Patch (Add Sidecar)

# apps/checkout-service/overlays/production/patch-sidecar.yaml
# HARDENED: Add observability sidecar via JSON patch
- op: add
  path: /spec/template/spec/containers/-
  value:
    name: otel-collector
    image: otel/opentelemetry-collector:0.96.0
    ports:
      - containerPort: 4317
    resources:
      requests:
        cpu: 50m
        memory: 64Mi
      limits:
        cpu: 200m
        memory: 128Mi
# apps/checkout-service/overlays/production/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
  - ../../base
patches:
  - path: patch-resources.yaml
  - path: patch-sidecar.yaml
    target:
      kind: Deployment
      name: checkout-service
images:
  - name: ghcr.io/acme/checkout-service
    newTag: abc123
configMapGenerator:
  - name: checkout-config
    behavior: merge
    literals:
      - ENVIRONMENT=production
      - LOG_LEVEL=info
      - CATALOG_URL=http://catalog-service.production.svc.cluster.local

Kustomize Components for Cross-Cutting Concerns

# components/observability/kustomization.yaml
# Reusable component: add to any service
apiVersion: kustomize.config.k8s.io/v1alpha1
kind: Component
patches:
  - target:
      kind: Deployment
    patch: |
      - op: add
        path: /spec/template/metadata/annotations/prometheus.io~1scrape
        value: "true"
      - op: add
        path: /spec/template/metadata/annotations/prometheus.io~1port
        value: "8080"
# apps/checkout-service/overlays/production/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
  - ../../base
components:
  - ../../../../components/observability
  - ../../../../components/network-policy
patches:
  - path: patch-resources.yaml

When Kustomize Breaks Down

Kustomize is not appropriate when:

  1. More than 5 overlays all modify the same fields differently: At this point, you are fighting the merge semantics. Use a Helm chart with a values file per variant.

  2. Consumers outside your team need to configure the manifests: Kustomize requires access to the base YAML. Helm packages everything into a chart that consumers install with helm install.

  3. Complex conditional logic: Kustomize has no if/else. If a resource should only exist in certain environments, you need separate base directories, which duplicates resources.

  4. Dependency management: Kustomize has no dependency resolution. If your manifests depend on a CRD being installed first, use Helm’s hooks or ArgoCD sync waves.

The Gate

kustomize build for every overlay in CI:

for overlay in apps/*/overlays/*/; do
  echo "Building $overlay..."
  kustomize build "$overlay" > /dev/null || exit 1
done

Additionally, validate that overlays do not accidentally remove critical fields:

for overlay in apps/*/overlays/*/; do
  OUTPUT=$(kustomize build "$overlay")
  # Every deployment must have resource limits
  if ! echo "$OUTPUT" | yq '.spec.template.spec.containers[0].resources.limits' | grep -q cpu; then
    echo "ERROR: $overlay missing CPU limits"
    exit 1
  fi
done

The Recovery

Patch does not apply: The base resource structure changed. Update the patch to match. Use kustomize build with --enable-alpha-plugins and the --stack-trace flag to see where the merge fails.

Image tag not updating: The images transformer matches by image name, not by tag. Ensure the name in the images list matches the image name in the base Deployment exactly (including the registry prefix).

ConfigMap changes do not trigger pod restart: Kustomize appends a hash suffix to generated ConfigMaps. When the content changes, the name changes, and the Deployment’s ConfigMap reference updates, triggering a rolling update. This only works with configMapGenerator, not with static ConfigMap resources.