skip to content

Why does helm upgrade leave a chart's crds/ CustomResourceDefinition untouched?

level: middleimportance: must knowfreq 64%

answer

  1. Upgrade works from the stored manifest
  2. Those files were never in it
  3. Cluster-scoped, shared, other people's data
  4. Deleting one takes the objects with it
  5. Either apply it yourself or move it

basics

~20 s

Helm has no safe way to change a CustomResourceDefinition — an edit can invalidate stored objects and a delete takes every custom resource with it. So crds/ is install-only: upgrades skip it, and a new chart version's schema change never lands.

solid answer

~50 s

`crds/` is applied by `helm install` and by nothing else. Rendered templates are stored in the release record and diffed on the next upgrade; CRDs from `crds/` never enter that record, so an upgrade has neither the intent nor the data to change one. The reasoning is blast radius: a CustomResourceDefinition is cluster-scoped shared state, an automated schema edit can invalidate objects other releases already stored, and deleting one removes every custom resource of that kind cluster-wide. Helm declines to guess, and **Helm 4 did not change this** — its server-side-apply default governs the release's rendered objects, not `crds/`. The practical consequences: bumping the chart version is not enough, `helm rollback` will not restore an earlier CRD either, and you either apply the new definition yourself or move the CRD into `templates/` with `helm.sh/resource-policy: keep` and accept the ownership and ordering costs that come with it.

code

yaml · 29 lines
yaml
# digestflow/templates/crd-digestschedule.yaml
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  name: digestschedules.digest.example.com
  annotations:
    helm.sh/resource-policy: keep
spec:
  group: digest.example.com
  scope: Namespaced
  names:
    kind: DigestSchedule
    plural: digestschedules
    singular: digestschedule
  versions:
    - name: v1
      served: true
      storage: true
      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
              properties:
                cron:
                  type: string
                retryBudget:
                  type: integer

go deeper

for a junior

Remember the rule and one reason for it: the definition is cluster-scoped shared state, so Helm applies it once at install and never edits or removes it afterwards. Do not reach for a flag that would force it.

for a middle

Explain the mechanism: upgrades diff the manifest stored in the release record, and CRDs from crds/ were never stored there. Then name the two ways out — apply the definition yourself, or move it into templates/ with helm.sh/resource-policy: keep.

for a senior

Show the consequences you have hit: rollback not restoring a definition, a second install colliding on a cluster-scoped object, and a custom resource racing the registration of its own kind when the definition moves into templates/.

for a principal

Take a position on where the definition should live for the estate you run — in the app chart, in a platform-owned artifact, or applied by a pipeline step — and be able to justify it in terms of blast radius and who is allowed to write cluster-scoped objects.

### The mechanism Helm's upgrade logic is built entirely on the release record. Each revision stores the manifest that was rendered from `templates/` in the Secret `sh.helm.release.v1.<name>.v<rev>`; `helm upgrade` renders the new chart, compares it with that stored manifest, and writes the difference. CRDs installed from `crds/` are never rendered and never stored there, so an upgrade has no old copy to compare against and no instruction to apply the new one. It does not skip them because of a bug or a missing flag — there is simply no code path from `helm upgrade` to `crds/`. So `--force` (in Helm 4 a deprecated alias of `--force-replace`) will not do it, `--force-conflicts` will not do it — that flag is about overriding field-manager conflicts under server-side apply — and neither will bumping `version` in `Chart.yaml`. ### Why it is designed this way A CustomResourceDefinition is not a namespaced object belonging to your release. It is cluster-scoped, it is typically shared by every release of that chart in every namespace, and objects that other teams created are validated and stored against it. Two failure modes drove the decision: - **An automated edit can invalidate stored data.** Changing a schema is not a text edit; existing stored objects were written against the old one. Helm has no per-chart knowledge of whether a change is additive or breaking, so it cannot decide when a patch is safe. - **A delete is catastrophic and irreversible.** Removing a CustomResourceDefinition removes every custom resource of that kind across the whole cluster. If Helm managed CRDs as ordinary release resources, dropping the file from a chart, or uninstalling one release out of several, would quietly destroy other people's objects. Given those, refusing to act is the conservative and defensible choice. It is a known sharp edge rather than an oversight, and it has survived every major version so far. ### What this means for a rollback Worth saying out loud in an interview because it surprises people: `helm rollback` re-applies a stored revision's rendered manifest. CRDs were never in that manifest, so rolling a release back from chart 2.6.1 to 2.4.7 leaves whatever CustomResourceDefinition is on the cluster in place. If the newer chart's schema change was additive, that is harmless. If it was not — a field removed, a version un-served — the rolled-back release renders custom resources the current definition rejects, and the rollback itself fails. ### The two honest options **Apply the CRD out of band.** Treat the definition as a separate, deliberate step: read it out of the exact chart version you are about to install with `helm show crds`, apply it, then run the chart upgrade. This keeps `crds/` doing what it is good at — bootstrapping a fresh cluster — while a pipeline step handles the change on clusters that already have the release. **Put the CustomResourceDefinition in templates/ instead.** Then it is an ordinary release resource: templated, gateable on a value, and patched on every upgrade like anything else. What you take on: - **Deletion.** `helm uninstall` now removes it, and every custom resource of that kind with it. `helm.sh/resource-policy: keep` on the manifest prevents that, at the cost of leaving an orphan behind that nothing will ever clean up. - **Ownership.** Only one release can own a cluster-scoped object. Install the same chart in a second namespace and the second install fails, because the object already exists carrying another release's `meta.helm.sh/release-name` and `meta.helm.sh/release-namespace` annotations. - **Ordering.** Helm sorts a CustomResourceDefinition early in its install order, but a custom resource of that new kind rendered by the same release can still be rejected because the API server has not finished registering the kind. `crds/` exists precisely to remove that race. ### The history, briefly The `crds/` directory arrived with chart `apiVersion: v2` as the replacement for the old `crd-install` hook, and the install-once rule has not moved since. If someone asks whether Helm 4 fixed CRD upgrades, the answer is no: Helm 4 changed the default apply strategy, the wait strategy and several flag names, and left `crds/` semantics exactly where they were. Anyone claiming otherwise is guessing.

  • Does helm rollback restore the CustomResourceDefinition the earlier revision shipped?
    No. A rollback re-applies a stored revision's rendered manifest, and CRDs from `crds/` were never in it, so the cluster keeps whatever definition it currently has. Rolling back to an older chart while the newer CRD stays is fine if the schema change was additive, and fails outright if the older chart renders custom resources the current definition no longer accepts.
  • If you move the CustomResourceDefinition into templates/ to get upgrades, what breaks first?
    Installing the same chart a second time in another namespace. Only one release can own a cluster-scoped object, so the second install fails because the definition already carries the first release's `meta.helm.sh/release-name` and `meta.helm.sh/release-namespace` annotations. Right behind it is uninstall, which now deletes the definition and every custom resource of that kind unless you annotate it `helm.sh/resource-policy: keep`.
  • Why is Helm's refusal to delete a CRD on uninstall a deliberate safety property rather than an omission?
    Because removing a CustomResourceDefinition removes every custom resource of that kind across the whole cluster, including objects created by other teams and other releases. Uninstalling one release out of several must not destroy shared data, and Helm has no way to know whether the definition is still in use. It leaves the definition behind and accepts the orphan instead.

saying these in an interview costs you the question

  • Claims --force makes an upgrade replace the CRD
  • Thinks Helm diffs CRDs like any other resource
  • Expects a chart version bump to trigger a CRD update
  • Says Helm 4 fixed CRD upgrades
  • Believes helm rollback restores the earlier CRD
  • Moves the CRD to templates/ without resource-policy keep

context