This document covers operational safety when migrating an installed Helm release from one chart to another (e.g. eks-1.0.1 → client-1.0.3), where you must preserve the underlying PVC/PV data across the uninstall/install cycle.
Read this before you plan any chart migration. Skipping the pre-flight verification can cost you PVCs even when you think you've protected them.
Helm reads
helm.sh/resource-policy: keepfrom the release's stored manifest (thesh.helm.release.v1.<release>.v<N>Secret), NOT from the live resource.Running
kubectl annotate pvc X helm.sh/resource-policy=keepon a live resource does nothing athelm uninstalltime if the chart template didn't render that annotation. Helm will delete the PVC anyway.
This bit us on 2026-04-22, and a related variant bit us again on 2026-04-27. See § Case studies at the end.
Before uninstalling the old release, check whether its stored manifest has the keep annotation on every resource you intend to preserve:
helm get manifest <release> -n <ns> | grep -B2 -A1 'resource-policy'For every PVC / StorageClass / Namespace / Secret you must keep, verify the annotation is present in the output. If it's missing, do not rely on live-annotating them — pick one of the three options below.
There are two real options — A (preferred) and C (fallback). A third approach, stripping live Helm ownership labels (Option B), looks like it should work and reliably gets reached for first, but does not work; we document it below as a cautionary tale so operators don't burn a maintenance window discovering this on a production migration. We did, twice.
If the old chart supports passing extra annotations on PVCs via values (or you have a post-renderer), run an upgrade that adds helm.sh/resource-policy: keep. This updates the stored manifest. Subsequent helm uninstall honours it.
helm upgrade <release> <old-chart> -n <ns> \
-f <current-values>.yaml \
--set-json 'pvcAnnotations={"helm.sh/resource-policy":"keep"}'
# verify the annotation is now in the stored manifest:
helm get manifest <release> -n <ns> | grep 'resource-policy'Only works if the chart exposes an annotations value. Many older charts don't.
You will be tempted to do this. Don't. It's the same class of mistake as kubectl annotate ... helm.sh/resource-policy=keep on a live PVC and expecting helm uninstall to honour it.
The intuition is: "if I remove app.kubernetes.io/managed-by=Helm and the meta.helm.sh/release-name annotation from the live resource, Helm will see it as un-owned and skip it on uninstall."
# PVC example — DOES NOT prevent uninstall from deleting the PVC
kubectl label pvc <pvc> -n <ns> app.kubernetes.io/managed-by-
kubectl annotate pvc <pvc> -n <ns> \
meta.helm.sh/release-name- \
meta.helm.sh/release-namespace-Why it fails: same root cause as the keep-annotation gotcha at the top of this doc. helm uninstall iterates the resource list in the stored release manifest (the sh.helm.release.v1.<release>.v<N> Secret). For each entry it issues a DELETE — without re-checking the live resource's ownership labels first. Those labels matter for helm install's adoption decision, not uninstall's deletion decision.
We learned this on 2026-04-27 by trying it on a StorageClass during the tenant-d-prod migration. The strip succeeded (live labels visibly empty), helm uninstall ran, and the StorageClass got deleted anyway. See § Case studies.
If you find yourself reaching for Option B, you actually want Option A or Option C.
If neither A nor B works and the underlying storage is Retain, the data survives PVC deletion. You'll need to rebuild the PVCs manually after uninstall:
# 1. Uninstall (PVCs deleted; PVs go to Released; underlying EFS/EBS intact)
helm uninstall <release> -n <ns>
# 2. Clear claimRef on each PV so it becomes Available
kubectl patch pv <pv-name> --type=json \
-p='[{"op":"remove","path":"/spec/claimRef"}]'
# 3. Re-create PVCs with spec.volumeName pointing at the retained PV
cat <<EOF | kubectl apply -f -
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: <pvc-name>
namespace: <ns>
annotations:
helm.sh/resource-policy: keep
meta.helm.sh/release-name: <new-release-name>
meta.helm.sh/release-namespace: <ns>
labels:
app.kubernetes.io/managed-by: Helm
spec:
storageClassName: <sc>
accessModes: [ReadWriteMany]
resources:
requests:
storage: <size> # must match PV capacity
volumeName: <pv-name>
EOFOnly safe if the PV's reclaimPolicy is Retain. With Delete you lose data the moment the PV goes Released.
helm get manifest <release> -n <ns> | grep resource-policy— keep annotation present for every resource you must preserve?kubectl get pv $(kubectl get pvc -n <ns> -o jsonpath='{.items[*].spec.volumeName}') -o custom-columns=NAME:.metadata.name,RECLAIM:.spec.persistentVolumeReclaimPolicy— allRetain?- Logical backup taken (for MySQL:
mysqldump | gzip > backup.sql.gzinside the pod, thenkubectl cpout —kubectl exec > filestreams of >100 MB silently truncate)? - Snapshot of the underlying storage (EFS / EBS / NFS) taken?
- Translated values file rendered (
helm template ... --dry-run=server) and reviewed? - PVC name + storageClassName + size in the new chart match the existing PVCs?
- No active workloads in the namespace beyond the release itself.
kubectl get jobs -n <ns>for spawned training Jobs;kubectl get pods -n <ns>for anything else mounting the release's PVCs. Active pods hold PVCs inTerminatingvia thekubernetes.io/pvc-protectionfinalizer (their CSI mounts keep the PVC alive until the pod releases the volume), which can stall the migration for hours. Either wait for them to drain, or — if losing in-flight work is acceptable —kubectl delete jobs -n <ns> --allbeforehelm uninstall. We missed this during the 2026-04-27tenant-d-prodmigration; 9 customer training Jobs were live and we had to delete them mid-migration.
Only proceed to helm uninstall when 1 or 2+3+4 is satisfied, and 5+6+7 are verified.
Two real migrations on the prod EKS cluster, both eks-1.0.x → client-1.x. Both ran the wrong protection trick first; both recovered via Option C. The pattern of failure has now bitten us twice on different live-resource-modification approaches; assume the next variant of "I'll just modify the live resource and helm will respect it" will also fail.
- Source: release
traceblocon charteks-1.0.1 - Target: chart
client-1.0.3 - What we did: annotated PVCs + StorageClass with
helm.sh/resource-policy: keepviakubectl annotate, then ranhelm uninstall. - What happened:
helm uninstalldeleted all 3 PVCs + the StorageClass despite the live annotations. The uninstall output did not include the usual "These resources were kept due to the resource policy" block. - Root cause: the
eks-1.0.1chart didn't template the keep annotation on PVCs. The stored release manifest had no annotation → Helm had no reason to skip them. - Recovery: PVs had
reclaimPolicy: Retainand EFS access points survive PV deletion, so no data was lost. We applied Option C above: recreated the StorageClass, clearedclaimRefon the 3 PVs, re-created PVCs pointing at the retained PVs viaspec.volumeName.helm install <new>then adopted the PVCs. MySQL came up with all 400K rows intact; shared and logs EFS directories intact. - Lesson: never trust
kubectl annotatealone to protect live Helm-owned resources. Always verify viahelm get manifest.
- Source: release
tenant-d-prodon charteks-1.0.3intracebloc-templates-prod - Target: chart
client-1.1.0 - What we did: read this doc, identified that the StorageClass
client-storage-classwas Helm-owned (live:app.kubernetes.io/managed-by=Helm,meta.helm.sh/release-name=tenant-d-prod) with nokeepannotation in the stored manifest, then tried Option B exactly as previously documented — stripped the live ownership label and the two release annotations. - What happened: strip succeeded (verified live:
managed-by=andrelease-name=both empty).helm uninstallran. The StorageClass was deleted anyway, the PVCs went intoTerminating, and we discovered mid-uninstall that 9 customer training Jobs were holdingclient-pvcandclient-logs-pvcviapvc-protectionfinalizers. - Root cause:
helm uninstalliterates the stored release manifest's resource list and DELETEs each entry. It does not re-check the live resource's ownership before deleting. The label-strip is theatrical — it changes whathelm installwould adopt, not whathelm uninstalldeletes. Same root cause as the 2026-04-22 keep-annotation case, different mistake. This is why Option B is now documented as "DOES NOT WORK" above. - Compounding finding: the pre-uninstall checklist had no item for active workloads in the namespace. The 9 training Jobs were spawned dynamically by
jobs-managerand were not Helm-owned, sohelm uninstallleft them alone — but their pods kept the PVCs mounted, blocking deletion. - Recovery: the user accepted losing the 9 in-flight training runs, so
kubectl delete jobs -n tracebloc-templates-prod --allreleased the PVCs. From there, Option C as documented: clearedclaimRefon the 3 PVs (Retain saved us again), re-created the StorageClass from the old values, pre-created PVCs withvolumeNameand the new release's Helm ownership stamp, thenhelm install tenant-d-prod ./client-1.1.0. mysql came up with 63 tables intact; the chart adopted the pre-created PVCs cleanly. Three independent backups (logical mysqldump, on-demand AWS Backup, daily automated AWS Backup) were untouched. - Lessons: (1) Option B was a doc bug — fixed in this revision. (2) Pre-uninstall checklist now requires verifying no active workloads beyond the release itself. (3) When tempted to modify a live Helm-managed resource and expect uninstall to respect it, assume it won't until proven otherwise via
helm get manifest.
The new unified client chart DOES template helm.sh/resource-policy: keep on all three PVCs (client-pvc, client-logs-pvc, mysql-pvc). Confirmed on 2026-04-22 by uninstalling the tb-client-test release — Helm correctly reported the 3 PVCs + namespace as kept.
For migrations away from a release of this chart, Option A/B/C still apply if the destination chart uses different resource names. For migrations between versions of this chart, the keep annotation is already in the stored manifest — a regular helm upgrade is safe.