Kustomize is the declarative, template-free way to manage k8s manifests. It overlays patches on top of base manifests, no templating language needed. Built into kubectl, supported by every GitOps controller, and almost always the right choice for “I need different configs per environment.”
The problem it solves
You have the same Deployment running in dev, staging, and prod. They differ in:
- Number of replicas (1 / 2 / 5)
- Image tag (
:dev/:staging/:v1.2.3) - Resource limits
- Environment variables
- Ingress hostnames
Naive solution: maintain 3 copies of the Deployment. Drift. Pain.
Helm solution: one template, three values files. Powerful but templates are complex (Go template language, lots of logic).
Kustomize solution: one base, three overlays. No templating language. Pure yaml patches.
The structure
my-app/
├── base/ # the source of truth
│ ├── deployment.yaml
│ ├── service.yaml
│ ├── configmap.yaml
│ ├── kustomization.yaml # the base kustomization
│ └── namespace.yaml
└── overlays/
├── dev/
│ ├── kustomization.yaml # the dev overlay
│ ├── patch-replicas.yaml
│ └── patch-resources.yaml
├── staging/
│ ├── kustomization.yaml
│ └── patch-configmap.yaml
└── prod/
├── kustomization.yaml
├── patch-replicas.yaml
├── patch-resources.yaml
├── patch-hpa.yaml
└── ingress.yaml
The base is unchanged across environments. The overlays add/transform.
The base
# base/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- deployment.yaml
- service.yaml
- configmap.yaml
- namespace.yaml
# common labels added to all resources
labels:
- includeSelectors: false
pairs:
app.kubernetes.io/name: my-app
app.kubernetes.io/managed-by: kustomize
# common annotations
annotations:
contact: ops@example.com# base/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-app
spec:
replicas: 3
selector:
matchLabels:
app: my-app
template:
metadata:
labels:
app: my-app
spec:
containers:
- name: my-app
image: myregistry/myapp:latest
ports:
- containerPort: 8080
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 512Mi
envFrom:
- configMapRef:
name: my-app-configThe overlays
Dev overlay
# overlays/dev/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: dev # all resources go in dev namespace
resources:
- ../../base
# patch the deployment
patches:
- path: patch-replicas.yaml
- path: patch-resources.yaml
- path: patch-image.yaml
# override the configmap
configMapGenerator:
- name: my-app-config
behavior: merge
literals:
- LOG_LEVEL=debug
- ENVIRONMENT=dev# overlays/dev/patch-replicas.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-app
spec:
replicas: 1 # dev runs 1 replica# overlays/dev/patch-resources.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-app
spec:
template:
spec:
containers:
- name: my-app
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
cpu: 200m
memory: 256Mi# overlays/dev/patch-image.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-app
spec:
template:
spec:
containers:
- name: my-app
image: myregistry/myapp:devProd overlay
# overlays/prod/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: prod
resources:
- ../../base
- ingress.yaml # prod-specific
patches:
- path: patch-replicas.yaml
- path: patch-resources.yaml
- path: patch-image.yaml
- path: patch-hpa.yaml
configMapGenerator:
- name: my-app-config
behavior: merge
literals:
- LOG_LEVEL=info
- ENVIRONMENT=prodPatches
Patches are the heart of kustomize. Three types:
Strategic merge patch (default)
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-app
spec:
replicas: 5Merges with the base. Lists are merged by name.
JSON merge patch (RFC 7396)
- op: replace
path: /spec/replicas
value: 5JSON-patch syntax. Use when you need precise control.
JSON patch (RFC 6902)
- op: add
path: /spec/template/spec/containers/0/env
value:
- name: NEW_VAR
value: newvalueMost precise. Useful for adding to lists.
Common operations
Add a label to all resources
labels:
- includeSelectors: true
pairs:
environment: prod
cost-center: engineeringincludeSelectors: true also adds to selector fields (so the label is in the matchLabels).
Override the namespace
namespace: prodAll resources get the prod namespace.
Override the name prefix
namePrefix: prod-my-app becomes prod-my-app. Useful for shared clusters.
Override the name suffix
nameSuffix: -v1my-app becomes my-app-v1.
Image transformation
images:
- name: myregistry/myapp # match the base image
newName: myregistry/myapp-prod
newTag: v1.2.3Useful in CI: set the image tag dynamically without patching the deployment.
ConfigMap / Secret generation
configMapGenerator:
- name: my-app-config
literals:
- KEY=value
files:
- config.json
secretGenerator:
- name: my-app-secret
literals:
- password=secret
type: OpaqueGenerates a new ConfigMap/Secret with a hash suffix. When the contents change, the hash changes, triggering a rolling update.
Disable hashing (if you have a hardcoded reference):
generatorOptions:
disableNameSuffixHash: trueCommon labels and annotations
commonLabels:
app: my-app
environment: prod
commonAnnotations:
owner: ops@example.com
runbook: https://wiki.example.com/runbooks/my-appPatch with reference
patches:
- target:
group: apps
version: v1
kind: Deployment
name: my-app
patch: |-
- op: replace
path: /spec/replicas
value: 5Components (reusable pieces)
# components/monitoring.yaml
apiVersion: kustomize.config.k8s.io/v1alpha1
kind: Component
resources:
- servicemonitor.yaml
- prometheusrule.yaml# overlay
components:
- ../components/monitoringReusable across many apps.
The kustomize CLI
Build and view output
# build and print the result
kubectl kustomize overlays/prod
# apply directly
kubectl apply -k overlays/prod
# build with a specific file
kustomize build overlays/prodEdit a resource
# set an image
kustomize edit set image myregistry/myapp=myregistry/myapp:v1.2.3
# set a namespace
kustomize edit set namespace prod
# add a label
kustomize edit add label environment:prod
# add a resource
kustomize edit add resource deployment.yamlThese edit the kustomization.yaml file in place.
Kustomize in CI/CD
Image tag injection in CI
# in CI
cd overlays/prod
kustomize edit set image myregistry/myapp=myregistry/myapp:$BUILD_TAG
git commit -am "bump to $BUILD_TAG"
git pushThe CI doesn’t patch the deployment — it patches the kustomization. The git diff is reviewable.
Generate manifests in CI
# generate the final manifests
kustomize build overlays/prod > /tmp/manifests.yaml
# (or apply directly)
kubectl apply -k overlays/prod
# validate
kubectl apply -k overlays/prod --dry-run=serverDiff between environments
# diff dev vs prod
diff <(kustomize build overlays/dev) <(kustomize build overlays/prod)Useful for auditing what differs.
Kustomize in GitOps
Argo CD and Flux both support kustomize natively.
Argo CD
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: my-app-prod
spec:
source:
repoURL: https://github.com/myorg/my-app
path: overlays/prod
targetRevision: HEAD
destination:
server: https://kubernetes.default.svc
namespace: prodArgo CD runs kustomize build on the path. No need to commit generated manifests.
Flux
apiVersion: kustomize.toolkit.fluxcd.io/v1
kind: Kustomization
metadata:
name: my-app-prod
spec:
path: ./overlays/prod
interval: 10m
prune: true
sourceRef:
kind: GitRepository
name: my-appFlux’s Kustomization CRD is essentially kustomize build + apply.
Kustomize vs Helm
| Use case | Kustomize | Helm |
|---|---|---|
| Plain yaml, just config diffs | ✅ best | Overkill |
| Templating, logic, conditionals | ❌ not great | ✅ best |
| Library reuse | Components | Library charts |
| Package distribution | ❌ not for that | ✅ OCI registries |
| Operator-friendly | ✅ | ✅ |
| Built into kubectl | ✅ | ❌ separate CLI |
| GitOps | ✅ | ✅ (with values) |
| Learning curve | Low | Medium-High |
| Industry adoption | High | Very High |
Use Kustomize when you have a base manifest and need environment-specific overlays.
Use Helm when you need templating, packaging, or a major project (Prometheus, cert-manager, etc.).
Use both: Helm for cluster components (CNI, ingress), Kustomize for app overlays.
Common gotchas
- Patches need the right
apiVersionandkind. Mismatches silently fail. configMapGeneratoradds a hash suffix to the name. Update the references.patchesStrategicMergeis deprecated in favor ofpatcheswith strategic merge syntax.includeSelectors: trueis needed for some labels (e.g., inspec.selector.matchLabels).- Order matters in
resources:— kustomize processes them in order, and some operations depend on the result of others. - Multi-document YAML in resources needs
---separators. - Kustomize is pure yaml — no logic, no loops. If you need logic, use Helm.
- Image transformations require the image name to match exactly.
- Generated Secrets/ConfigMaps are immutable by default. Use
generatorOptions: { disableNameSuffixHash: false }to keep updates working. - Patches in separate files are easier to read. Don’t put all patches inline.
namespace:is set on the overlay, not the base. The base is namespace-agnostic.- The
kustomizeCLI is separate fromkubectl kustomize. Use the standalone for full features; kubectl’s built-in is missing some.
The “I have 50 overlays” anti-pattern
If you find yourself with 50 overlays, you’re using kustomize wrong.
Better: fewer overlays with components.
# overlays/prod/kustomization.yaml
components:
- ../../components/monitoring
- ../../components/security-baseline
- ../../components/production-tuning
- ../../components/ingress-public
resources:
- ../../baseComponents are reusable, parameterizable pieces. They replace the copy-paste of overlays.
A worked example
Goal: a web service with:
- Different replicas/resources per env
- Different config (log level, DB connection)
- Production has HPA, ingress, monitoring
- Dev has 1 replica, no HPA
- Common monitoring and security across all envs
Structure:
my-app/
├── base/
│ ├── kustomization.yaml
│ ├── deployment.yaml
│ ├── service.yaml
│ ├── configmap.yaml
│ └── serviceaccount.yaml
├── components/
│ ├── monitoring.yaml
│ │ ├── kustomization.yaml
│ │ ├── servicemonitor.yaml
│ │ └── prometheusrule.yaml
│ ├── security-baseline.yaml
│ │ ├── kustomization.yaml
│ │ ├── networkpolicy.yaml
│ │ └── podsecuritycontext.yaml
│ └── production-tuning.yaml
│ ├── kustomization.yaml
│ ├── pdb.yaml
│ └── topology-spread.yaml
└── overlays/
├── dev/
│ ├── kustomization.yaml
│ ├── patch-replicas.yaml
│ └── patch-config.yaml
├── staging/
│ ├── kustomization.yaml
│ ├── patch-replicas.yaml
│ └── patch-config.yaml
└── prod/
├── kustomization.yaml
├── patch-replicas.yaml
├── patch-config.yaml
├── patch-image.yaml
├── hpa.yaml
└── ingress.yaml
Dev:
# overlays/dev/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: dev
resources:
- ../../base
components:
- ../../components/monitoring
- ../../components/security-baseline
patches:
- path: patch-replicas.yaml
- path: patch-config.yaml
configMapGenerator:
- name: my-app-config
behavior: merge
literals:
- LOG_LEVEL=debug
- DB_HOST=db.dev.example.comProd:
# overlays/prod/kustomization.yaml
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: prod
resources:
- ../../base
- hpa.yaml
- ingress.yaml
components:
- ../../components/monitoring
- ../../components/security-baseline
- ../../components/production-tuning
patches:
- path: patch-replicas.yaml
- path: patch-config.yaml
- path: patch-image.yaml
images:
- name: myregistry/myapp
newName: myregistry/myapp
newTag: v1.2.3
configMapGenerator:
- name: my-app-config
behavior: merge
literals:
- LOG_LEVEL=info
- DB_HOST=db.prod.example.comBuild prod:
kustomize build overlays/prodApply:
kubectl apply -k overlays/prodSee also
- helm-cicd — when to use Helm instead
- gitops-basics — kustomize + GitOps
- Kustomize docs
- Kustomize cheatsheet