Skip to content

CRD Ordering: kubectl First, Helm Second

FieldValue
TypeSkill Resource
Source~/.copilot/skills/platform/references/helm/crd-ordering.md
DescriptionNot specified

Source Content

CRD Ordering: kubectl First, Helm Second

The single hardest rule in this skill. CRDs are cluster-scoped schema; Helm is a release manager. Letting Helm own CRD lifecycle means an uninstall or a chart bump can delete or mutate the schema that other releases depend on. So CRDs install out of band, by a kubectl apply task, before any dependent Helm release. This is ADR-028 (platform foundations).

Contents

The ordering

1. kubectl apply -f <crd-source> # cluster-scoped schema, idempotent
2. wait for CRDs to be Established
3. helm upgrade --install <release> # controllers + namespaced resources

Step 1 runs as a dedicated Taskfile target. Step 3’s chart is configured to install no CRDs. The two steps never merge.

Disabling in-chart CRDs

The opt-out key differs per chart — always confirm it on Artifact Hub. Common forms:

Chart familyOpt-out value
cert-managerinstallCRDs: false (also crds.enabled: false in newer charts)
ingress-nginxno CRDs shipped — nothing to disable
Prometheus (kube-prometheus-stack)crds.enabled: false
external-secretsinstallCRDs: false
CloudNativePGinstall the operator CRDs via kubectl first; chart references them

Backstops that always work regardless of chart:

  • helm install --skip-crds — Helm skips the chart’s crds/ directory.
  • Do not template CRDs into templates/ yourself; keep them in a kubectl-applied source.

The Taskfile pattern

This target belongs in the project Taskfile (author it with the go-task skill). It applies CRDs, waits for them, then runs Helm. Mirror the kubectl-task style from go-task’s Taskfile.kubectl.yml.

version: '3'
# Included under the `helm:` namespace by the root Taskfile:
# task helm:crds
# task helm:deploy ENV=prod
vars:
ENV: '{{.ENV | default "dev"}}'
NAMESPACE: '{{.NAMESPACE | default "p3-app"}}'
RELEASE: '{{.RELEASE | default "app"}}'
CHART: '{{.CHART | default "./chart"}}'
# Pin the CRD source to an exact version — never a moving ref.
CERT_MANAGER_CRDS: 'https://github.com/cert-manager/cert-manager/releases/download/v1.16.2/cert-manager.crds.yaml'
tasks:
crds:
desc: "Apply CRDs out of band, before any Helm release depends on them"
cmds:
- kubectl apply --server-side -f {{.CERT_MANAGER_CRDS}}
- kubectl wait --for=condition=Established --timeout=120s crd --all
deploy:
desc: "Apply CRDs first, then install/upgrade the Helm release"
deps: [crds]
cmds:
- >-
helm upgrade --install {{.RELEASE}} {{.CHART}}
--namespace {{.NAMESPACE}} --create-namespace
--skip-crds
--values values.yaml
--values values-{{.ENV}}.yaml

Notes:

  • deps: [crds] makes the CRD apply a hard prerequisite of every deploy.
  • --server-side apply is the safe default for large CRDs (avoids the last-applied-annotation size limit).
  • --skip-crds is the backstop even when the chart’s values already disable CRDs.
  • --values values.yaml --values values-{{.ENV}}.yaml layers the env override on the base.

Why not Helm hooks

Helm CRD hooks and the crds/ directory both tie CRD lifetime to release lifetime. A helm uninstall can then remove CRDs that other releases still need, and a CRD schema change becomes a release-coupled migration instead of a deliberate cluster operation. Applying CRDs with kubectl keeps them cluster-scoped, idempotent, and independent of any single release — which is exactly what ADR-028 requires.