You Don’t Need Helm
Here’s the thing: Helm is to Kubernetes what a luxury SUV is to off-roading — technically more capable, but if you just need to move a family of five to the grocery store, you’re paying for features that’ll never leave the parking lot.
For home labs running a handful of services on k3s, Helm introduces complexity you don’t need. Templating languages, value files, chart dependencies, repository management — it all adds friction. Meanwhile, Kustomize sits there, quiet, doing exactly one thing well: letting you manage Kubernetes manifests without templating syntax at all.
The secret sauce? Overlays. Base manifests + layer-specific customizations = clean separation, no Helm, no headaches.
Kustomize: The Overlay Pattern
Kustomize is built into kubectl (since 1.14). No additional tools. No dependencies. Just a kustomization.yaml file that tells kubectl how to patch, merge, and combine your YAML manifests.
The core idea:
- base/ — Production-ready manifests (Deployments, Services, ConfigMaps, etc.)
- overlays/{dev,staging,prod}/ — Thin layers that customize the base for each environment
No variables, no templating conditionals. You write real Kubernetes YAML. Overlays apply patches on top.
Think of it like a photo editor: the base is your original image, overlays are adjustment layers that change colors or crop without destroying the original.
Directory Structure
Here’s what a typical home lab setup looks like:
my-app/├── base/│ ├── kustomization.yaml│ ├── deployment.yaml│ ├── service.yaml│ └── configmap.yaml├── overlays/│ ├── dev/│ │ └── kustomization.yaml│ ├── staging/│ │ └── kustomization.yaml│ └── prod/│ └── kustomization.yaml└── .gitignoreSmall, obvious, no magic. Everyone on your team (even if it’s just you) knows what goes where.
Base Manifests
Your base is straightforward YAML — nothing special. Here’s a simple app:
apiVersion: apps/v1kind: Deploymentmetadata: name: webappspec: replicas: 2 selector: matchLabels: app: webapp template: metadata: labels: app: webapp spec: containers: - name: app image: myapp:latest ports: - containerPort: 8080 env: - name: LOG_LEVEL valueFrom: configMapKeyRef: name: app-config key: log_level resources: requests: cpu: 100m memory: 128Mi limits: cpu: 500m memory: 512MiapiVersion: v1kind: Servicemetadata: name: webappspec: type: ClusterIP ports: - port: 80 targetPort: 8080 selector: app: webappapiVersion: v1kind: ConfigMapmetadata: name: app-configdata: log_level: "info"apiVersion: kustomize.config.k8s.io/v1beta1kind: Kustomization
resources: - deployment.yaml - service.yaml - configmap.yaml
commonLabels: app: webapp managed-by: kustomizeThat’s it. The base kustomization.yaml just lists what files to include and applies common labels everywhere.
Overlays: Dev, Staging, Prod
Now the magic. Dev needs 1 replica, higher log level, smaller resources. Prod needs 3 replicas, info-only logs, more memory. Staging sits between.
Kustomize patches let you change specific fields without duplicating the entire manifest.
apiVersion: kustomize.config.k8s.io/v1beta1kind: Kustomization
bases: - ../../base
replicas: - name: webapp count: 1
patches: - target: kind: Deployment name: webapp patch: |- - op: replace path: /spec/template/spec/containers/0/env/0/value value: "debug" - op: replace path: /spec/template/spec/containers/0/resources/requests/memory value: 64Mi
configMapGenerator: - name: app-config behavior: merge literals: - log_level=debugapiVersion: kustomize.config.k8s.io/v1beta1kind: Kustomization
bases: - ../../base
replicas: - name: webapp count: 2
configMapGenerator: - name: app-config behavior: merge literals: - log_level=infoapiVersion: kustomize.config.k8s.io/v1beta1kind: Kustomization
bases: - ../../base
replicas: - name: webapp count: 3
patches: - target: kind: Deployment name: webapp patch: |- - op: replace path: /spec/template/spec/containers/0/resources/requests/memory value: 256Mi - op: replace path: /spec/template/spec/containers/0/resources/limits/memory value: 1Gi
configMapGenerator: - name: app-config behavior: merge literals: - log_level=warnSee what happened? Each overlay specifies only the differences. Dev changes replicas and debug mode. Prod ups the resource requests. Zero duplication. Change the base once, all overlays inherit it.
Building & Deploying
To preview what Kustomize will generate:
kubectl kustomize overlays/devTo apply directly:
kubectl apply -k overlays/prodThat -k flag is your friend. It tells kubectl “this is a Kustomization, build it and apply it.”
For GitOps workflows (ArgoCD, Flux), you point your Application or Kustomization resource at the overlay path:
apiVersion: argoproj.io/v1alpha1kind: Applicationmetadata: name: webapp namespace: argocdspec: project: default source: repoURL: https://github.com/you/my-app targetRevision: main path: overlays/prod destination: server: https://kubernetes.default.svc namespace: default syncPolicy: automated: prune: true selfHeal: trueArgoCD syncs whatever the kustomization produces. One Application resource per overlay (dev points to overlays/dev, prod points to overlays/prod), and you’re golden.
JSON Patches vs Strategic Merge Patches
That patch: |- stuff above is a JSON patch — it’s explicit but verbose if you’re changing multiple fields. Kustomize also supports strategic merge patches, which are more concise:
apiVersion: apps/v1kind: Deploymentmetadata: name: webappspec: replicas: 1 template: spec: containers: - name: app resources: requests: memory: 64MiThen reference it:
patches: - path: deployment-patch.yamlKustomize merges this onto the base Deployment, keeping fields you didn’t specify unchanged. Much cleaner for multi-field changes.
When Kustomize Wins (and When It Doesn’t)
Kustomize shines for:
- Home lab services — Small Deployment counts, few replicas per environment, simple config differences
- GitOps-first workflows — ArgoCD and Flux natively understand
kubectl apply -k; no Helm plugin needed - Teams avoiding Helm fatigue — No chart repos, no dependency hell, no secrets management burden
- Quick prototyping — Write YAML once, patch per environment, commit to git
- Predictability — What you see in your repo is what deploys (WYSIWYG); no surprise value interpolations
Helm is still better for:
- Package distribution — If you’re publishing a reusable app for others to install (e.g., Bitnami charts)
- Complex conditional logic — “Install this only if nodecount > 3” or “use this image tag only in prod” — Helm’s
if/elsehandles it; Kustomize doesn’t - Shared parameter management — If 100 services all need the same PostgreSQL connection string changed, Helm’s values at the chart level are less repetitive than Kustomize’s field generators
- Large enterprise multi-team setups — Where standardization and audit trails matter more than simplicity
For a home lab? Stick with Kustomize.
Real Example: Multi-Replica App with Ingress
Let’s build something closer to reality — a web app with an Ingress for external access:
myapp/├── base/│ ├── kustomization.yaml│ ├── deployment.yaml│ ├── service.yaml│ ├── ingress.yaml│ └── configmap.yaml├── overlays/│ ├── dev/│ │ ├── kustomization.yaml│ │ └── ingress-patch.yaml│ └── prod/│ ├── kustomization.yaml│ └── hpa.yamlBase Ingress (simple):
apiVersion: networking.k8s.io/v1kind: Ingressmetadata: name: webapp-ingressspec: ingressClassName: traefik rules: - host: myapp.localhost http: paths: - path: / pathType: Prefix backend: service: name: webapp port: number: 80Dev overlay changes the hostname and skips TLS:
apiVersion: networking.k8s.io/v1kind: Ingressmetadata: name: webapp-ingressspec: rules: - host: myapp-dev.localhostProd overlay adds TLS and a real domain:
apiVersion: kustomize.config.k8s.io/v1beta1kind: Kustomization
bases: - ../../base
patches: - target: kind: Ingress name: webapp-ingress patch: |- - op: replace path: /spec/rules/0/host value: myapp.example.com - op: add path: /spec/tls value: - secretName: myapp-tls hosts: - myapp.example.com
resources: - hpa.yamlAnd hpa.yaml (only in prod):
apiVersion: autoscaling/v2kind: HorizontalPodAutoscalermetadata: name: webapp-hpaspec: scaleTargetRef: apiVersion: apps/v1 kind: Deployment name: webapp minReplicas: 3 maxReplicas: 10 metrics: - type: Resource resource: name: cpu target: type: Utilization averageUtilization: 70Now:
kubectl apply -k overlays/dev— 1 replica, localhost domain, no autoscaling, debug logskubectl apply -k overlays/prod— 3 replicas, HPA up to 10, real domain + TLS, warn-level logs
Base is untouched. Changes cascade automatically.
The Decision: Kustomize or Helm?
Ask yourself:
- Are you deploying a Helm chart from someone else? → Use Helm, you have no choice.
- Are you running a home lab with <20 services? → Kustomize. Full stop.
- Do you have non-engineer team members installing your app? → Helm charts are more polished for distribution.
- Are you managing 50+ services with complex cross-service configuration? → Helm’s centralized values (or Kustomize with a generator framework) becomes attractive.
- Is your team already comfortable with Helm? → Keep it, switching is friction.
For everyone else, especially home lab folks tired of Helm’s learning curve? Kustomize is the right tool. You get environment-specific manifests, GitOps-friendly output, and zero templating nonsense.
Your 2 AM self — the one debugging why a variable didn’t interpolate correctly — will thank you.