skip to content

How do Helm and Kustomize differ in how they produce the final manifests applied to a cluster?

level: juniorimportance: must knowfreq 78%

answer

  1. Two routes to the same applied YAML
  2. One generates the YAML, one patches it
  3. Only the knobs the author exposed
  4. One of the two remembers what it applied
  5. sh.helm.release.v1 Secret per revision

basics

~20 s

Helm renders a chart's Go templates against values, so the final YAML is generated from files that are not YAML. Kustomize patches complete YAML instead. Helm also stores each apply as a release; kubectl apply -k stores nothing.

solid answer

~50 s

Helm's unit is a chart: `Chart.yaml`, `values.yaml` and a `templates/` directory of Go templates. `helm install` and `helm upgrade` render those templates against the merged values and apply the result, so an object can be emitted conditionally with `if` or generated in a loop with `range` — but only the knobs the chart author exposed as values are reachable without forking. Kustomize goes the other way: its inputs stay valid Kubernetes YAML and a variant is produced by patching, so any field in the base is reachable, while conditionally emitting a whole object is not what patching is for. The second difference matters just as much: Helm records every apply as a release revision in a Secret in the namespace, which is what makes `helm list`, `helm get manifest`, `helm rollback` and `helm uninstall` possible. `kubectl apply -k` leaves no such record.

code

yaml · 29 lines
yaml
# templates/statefulset.yaml — not valid YAML until rendered
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: {{ .Release.Name }}-redis
spec:
  replicas: {{ .Values.replicaCount }}
  serviceName: {{ .Release.Name }}-redis
  selector:
    matchLabels:
      app: {{ .Release.Name }}-redis
  template:
    metadata:
      labels:
        app: {{ .Release.Name }}-redis
    spec:
      containers:
        - name: redis
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}"
{{- if .Values.persistence.enabled }}
  volumeClaimTemplates:
    - metadata:
        name: data
      spec:
        accessModes: ["ReadWriteOnce"]
        resources:
          requests:
            storage: {{ .Values.persistence.size }}
{{- end }}

go deeper

for a junior

Be ready to say in two sentences that Helm renders templates against values while Kustomize patches finished YAML, and that Helm additionally remembers each install as a release. Name Chart.yaml, values.yaml and templates/ without hesitating.

for a middle

Explain the mechanics: values merge into .Values, if and range decide what is emitted, and the render is stored as a release revision that later upgrades diff against. Say plainly which customisations each model makes easy and which it makes awkward.

for a senior

Show you have felt both costs in production: a chart knob that did not exist when you needed it, and an overlay estate with no answer to "what is actually deployed here?". Mention the post-renderer bridge rather than presenting the two as exclusive.

for a principal

Own the framing that this is a distribution and lifecycle decision, not a syntax preference. Charts are a versioned public interface for consumers you cannot redeploy; overlays are cheap when every consumer is you and you are willing to own deployment state elsewhere.

### Two different machines for producing YAML Both tools end up sending Kubernetes manifests to an API server, but they build those manifests in opposite ways, and the interview question is almost always really about that difference plus its consequences. **Helm generates.** A chart is a directory containing `Chart.yaml` (name, `version`, `appVersion`, dependencies), `values.yaml` (the defaults), and `templates/` — files written in Go's text/template dialect. `helm install` and `helm upgrade` merge the chart defaults with whatever the caller supplied, expose the result to the templates as `.Values` alongside the built-in objects `.Release`, `.Chart`, `.Capabilities`, `.Files` and `.Template`, render every file under `templates/`, and apply what comes out. The consequence people miss is that the files under `templates/` are not Kubernetes YAML at all before rendering — they are text with actions in them: ```yaml {{- if .Values.persistence.enabled }} volumeClaimTemplates: - metadata: name: data spec: resources: requests: storage: {{ .Values.persistence.size }} {{- end }} ``` A Redis chart written this way can emit a StatefulSet with a PersistentVolumeClaim for one caller and a plain Deployment with an `emptyDir` for another, from the same source, because whole blocks and whole objects appear and disappear. That expressive power has a price: the customisation surface is fixed in advance by the chart author. If the author never templated `topologySpreadConstraints`, a consumer cannot set it — there is no value to set. The options are then to fork the chart, upstream a new value, or post-process the rendered output. **Kustomize patches.** Its inputs are complete, valid manifests, and a variant is produced by applying patches to them. Nothing in the tree needs a rendering step before it can be read, linted or validated as a manifest, and no field is out of reach because nobody anticipated it — you can patch something the base's author never thought about. The symmetric cost is that "emit this object only in production" or "repeat this block once per queue" is not what patching expresses naturally. ### The second difference: state The rendering model is only half the answer, and candidates who stop there sound shallow. Helm keeps a record. Every `install`, `upgrade` and `rollback` writes a **release revision** — the rendered manifest, the values that produced it, the chart metadata and a status — into a Secret named `sh.helm.release.v1.<name>.v<rev>` in the release's namespace. That record is the basis of everything Helm can do that a plain apply cannot: - `helm list` enumerates what is installed in a namespace, without you knowing what to look for. - `helm get manifest <release>` prints exactly what that release applied — the render, after the fact. - an upgrade diffs the new render against the **stored previous manifest**, so a resource you deleted from the chart is deleted from the cluster. - `helm rollback` can re-apply an earlier revision because that revision's manifest is still on disk in the cluster. - `helm uninstall` knows precisely which objects belong to the release. `kubectl apply -k` has none of that: the built manifests are applied and forgotten. The cluster plus your source tree are the only state, so "what is deployed here?" and "put it back" are questions you answer with Git and a rebuild rather than with a command. ### The third difference: packaging Helm charts are also a **distribution format**. `helm package` produces a versioned `.tgz` that can be served from a classic chart repository with an `index.yaml`, or pushed to and installed from an OCI registry with an `oci://` reference. A chart therefore has an explicit public interface — `values.yaml`, optionally constrained by `values.schema.json` — that strangers depend on. That is why nearly everything you install from a vendor arrives as a chart, and why most organisations end up running Helm at least as a consumer regardless of what they choose for their own services. ### How to say it in an interview A crisp answer covers three axes: **generation versus patching** (templating power and a fixed knob surface, against unconstrained patching of real YAML); **stateful release versus stateless apply** (revision history, rollback, uninstall, deletion of removed resources, against nothing but the cluster); and **packaging** (a versioned, parameterised artifact for consumers you cannot redeploy yourself). Then say the honest thing: they are not mutually exclusive. Helm can hand its rendered output to a post-renderer, and plenty of teams consume third-party charts while writing their own services as plain manifests with overlays. One myth worth killing on the spot: Helm has no server-side component. Since Helm 3 there is no Tiller — the CLI renders locally and talks to the API server with your own credentials, exactly as `kubectl` does.

  • A chart does not expose the field you need as a value. What are your options short of forking it?
    Three. Upstream a new value into the chart if you can, which is the durable fix. Patch the rendered output with a post-renderer, so Helm still owns the release record. Or, for a small number of fields, layer a separate manifest of your own beside the release. Forking is the last resort because you then own every future upgrade of that chart.
  • Does Helm need to reach the cluster to render a chart's templates?
    Rendering itself is local Go templating, but `install` and `upgrade` populate `.Capabilities` from the API server's discovery data, so a chart branching on whether an API version exists needs a real connection to branch correctly. `helm template` renders offline against built-in defaults instead, which is why a chart can render differently there than it does on install.
  • Since Helm 3 there is no Tiller. What does that change about who is allowed to install a chart?
    The CLI talks to the API server as the user running it, so Helm can do exactly what your RBAC permits and nothing more. There is no cluster-wide privileged component that installs on your behalf, so chart installation is governed by ordinary Kubernetes authorization rather than by a separate grant.

Helm is a parameterised form letter: powerful, but you can only fill in the blanks the author left. Kustomize is editing a finished letter with a red pen: any word is fair game, but you cannot make whole paragraphs appear from a flag.

saying these in an interview costs you the question

  • Says Helm and Kustomize are the same idea with different syntax
  • Describes Kustomize as a template language with variables
  • Claims files under templates/ are valid YAML before rendering
  • Says Helm installs need a server-side Tiller component
  • Thinks kubectl apply -k leaves a release you can roll back
  • Assumes any chart field can be overridden without a value for it

context