[GCP] Helm Upgrade's Resource Deletion Mechanism — Anatomy Of A Namespace Wipeout On litellm-gke
Situation
Was Adding An HTTPS Listener To The PRD GKE Gateway. helm upgrade Got Stuck On A Field Manager Conflict, So I Threw --server-side --force-conflicts At It To Push Through — And It Wiped The Entire prd-litellm Namespace (Deployment / Pod / Service / HPA / Secret, All Gone). Recovery Worked, No Data Lost, But The Mechanism Is Worth Understanding Properly, Or I’ll Walk Into It Again.
Result First:
| Trigger Command | helm upgrade --server-side --force-conflicts |
| Blast Radius | Entire prd-litellm Namespace (Deployment / Pod / Service / HPA / Secret, All Gone) |
| Root Cause | Every Helm 3 Upgrade Diffs “Release Record” Against “This Render” — A Leftover Namespace Entry From An Old Revision Triggered The Delete |
| Is It A Bug | No, It’s Core Helm 3 Design Behavior |
| Recovery | Successful, No Data Loss |
| Incident | litellm-gke PRD, 2026-06-29 |
Turns Out Helm Upgrade Isn’t As Simple As “Apply The New Stuff.” Every Single Time, It Runs A Full Set Difference: Resources Recorded In The Last Release Minus Resources In This Render = What Gets Deleted. Break That Assumption Once (Some Resource That Should’ve Been Cleaned Out Of The Chart Long Ago Wasn’t) And Upgrade Turns Into A Delete Button — This Time It Was My Own Landmine Going Off.
How It Broke (Root Cause)
How A Release Gets Tracked
Every Helm 3 install/upgrade Stores The Full Rendered Manifest As A Secret (Default Backend), Living In The Release’s Namespace, Named sh.helm.release.v1.<release-name>.v<revision>. That Secret Is Helm’s Single Source Of Truth For “What Resources Does This Release Own.”
# See What Helm Thinks It Created "Last Time"
helm get manifest <release> -n <namespace>
The Three-Way Comparison On Upgrade
helm upgrade Runs A Three-Way Comparison:
① Last Release's Recorded Manifest (What's In The Secret)
② Current Live Cluster State
③ This Time's Freshly Rendered Chart Manifest
The Diff Between ① And ③ Decides What Gets CREATE / PATCH / DELETE:
| In ① | In ③ | Helm Action |
|---|---|---|
| ❌ | ✅ | CREATE (New Resource) |
| ✅ | ✅ | PATCH (Three-Way Merge Of ①②③ For Minimal Change) |
| ✅ | ❌ | DELETE ← Where This Incident Came From |
The Mechanism Itself Is Completely Reasonable — It’s What Lets “Removing A Resource From The Chart” Be Handled Automatically Without A Manual kubectl delete. The Problem: If ① Contains A Resource That Got Rendered By Accident On Some Old Revision And Was Never Supposed To Exist, It Just Sits In ① Until Some Future Upgrade’s ③ Finally Doesn’t Have It — And That’s When The Delete Fires. Could Be Months Later, In Someone Else’s Hands Entirely, With No Way To Trace “Why Is This Getting Deleted Now.”
Incident Timeline
Before 6/17 templates/namespace.yaml Had No {{- if }} Guard,
An Early Revision Deployed With createNamespace: true,
The Namespace Object Got Recorded Into That Release's ① (Manifest Record)
↓
Later Switched To createNamespace: false + {{- if .Values.createNamespace }} Guard,
But From Then On Every Rendered ③ No Longer Included The Namespace
(Regular Helm Upgrades Never Actually Triggered This Delete Path)
↓
2026-06-29 Field Manager Conflict Forced The Switch To
--server-side --force-conflicts
↓
① (Namespace In Record) − ③ (No Namespace In Render)
= Namespace Marked For Deletion
↓
kubectl Sends DELETE On The Namespace
↓
K8s Behavior: Deleting A Namespace Deletes "Everything" Inside It
(Deployment, Pod, Service, HPA, Secret — Even Helm's Own
Release Record Secret, All Taken Down With It)
One Command, The Whole Environment Gone — And What Got Blown Up Wasn’t Even Related To This Change (I Just Wanted To Add An HTTPS Listener). It Was A Completely Unrelated Piece Of Historical Baggage That Had Been Dormant For Months, Finally Detonated.
Client-Side vs --server-side
| Client-Side (Default) | --server-side |
|
|---|---|---|
| Where The Merge Happens | Locally In Helm (Three-Way ①②③ Comparison) | K8s API Server (Server-Side Apply, Tracks Field Ownership Via Field Manager) |
| Best For | General Use | Multiple Controllers/Tools Editing The Same Resource, Avoiding Mutual Overwrites |
| Delete-Determination Logic | Still ①−③ | Still ①−③ (Both Modes Do This, Not Server-Side-Exclusive) |
| Why Server-Side Triggered It This Time | — | The Field Manager Conflict Forced The Switch, Which Was The First Time This ①−③ Diff Actually Ran All The Way Through |
Key Misconception To Clear Up: It’s Not That --server-side Is More Dangerous. It’s That This Particular Upgrade — Which Happened To Use Server-Side — Was The First Time The Long-Dormant “Namespace Missing From ①” Discrepancy Actually Got Settled. This Landmine Was Going To Go Off Eventually, It Just Happened To Be Server-Side That Stepped On It.
Notes
kubectl diff -f -Is Blind To Deletions. It Only Tells You What Changed In The Content You Fed It — It Can’t Tell You What Disappeared From The List. This Was The Second Trap I Fell Into During The Postmortem: I Assumedkubectl diffWould Act As A Safety Net, Turns Out It’s Completely Useless For This.--server-sideWasn’t The Culprit, Just The Trigger. The ①−③ Delete Logic Runs In Both Modes — The Only Difference This Time Was The Field Manager Conflict Forcing The Switch, Which Was The First Time This Path Actually Got Exercised.- What Got Deleted Had Nothing To Do With What I Was Changing. I Just Wanted To Add An HTTPS Listener To The Gateway — The Namespace That Got Deleted Was Baggage Left Over From A Chart Change Months Earlier.
- Namespace / PVC / Secret Are The Most Dangerous Because Deletion Cascades. Delete A Namespace, Everything Inside Goes With It, Including Helm’s Own Release Record Secret.
How To Prevent It
The Core Tool: Diff The Resource “List,” Not The “Content”
The Right Way Is To List Out The Resources (Kind + Name) In ① And ③ Separately, Then Diff The Sets:
# ① Resource List From The Last Release's Recorded Manifest
helm get manifest <release> -n <namespace> | grep -E "^kind:|^ name:" > /tmp/before.txt
# ③ Resource List From This Time's Rendered Chart
helm template <release> ./chart -f values-<env>.yaml -n <namespace> \
| grep -E "^kind:|^ name:" > /tmp/after.txt
# Look At The Diff — Lines That Only Show Up In "before" Are What's About To Be Deleted
diff /tmp/before.txt /tmp/after.txt
If Any Unexpected “About To Be Deleted” Item Shows Up — Especially High-Blast-Radius Resources Like Namespace, PVC, Secret — Stop And Investigate Before Applying.
Scope The Blast Radius, Don’t Flip The Whole Release To Server-Side
A Field Manager Conflict Usually Happens On A Specific Field Of A Specific Resource, Not The Whole Release:
# Scenario One: A Resource Is Missing Its Helm Ownership Annotation (What Actually Happened Here — Safe Fix)
kubectl annotate <kind> <name> -n <namespace> \
meta.helm.sh/release-name=<release> \
meta.helm.sh/release-namespace=<namespace>
# Scenario Two: A Single Field Got Claimed By Another Controller
kubectl patch <kind> <name> -n <namespace> --type=json \
-p '[{"op":"replace","path":"/spec/xxx","value":"yyy"}]'
Both Are Surgical Fixes — Neither Triggers Helm’s Full ①−③ Diff Across The Whole Release.
Real-World Application
| Scenario | What To Do |
|---|---|
| Any Helm-Managed K8s Environment (Not Just GCP/GKE) | If A Chart Ever Changed Which Resources Get Rendered Under templates/ (Especially Namespace, PVC — Anything Where Deletion Cascades), Any Future Upgrade Could Be The Trigger. Build The Habit Of Running The ①−③ Diff Check Before Every Upgrade, Regardless Of How Important The Environment Is |
| Helm Chart Code Review Checklist | When Reviewing A Chart PR, Watch For Any Top-Level Resource (Namespace/PVC/CRD) Moving In Or Out Of An {{- if }} Condition — That Kind Of Change Carries A Hidden Deletion Risk On The Next Upgrade That Won’t Show Up In The PR Diff Itself (Because The Risk Comes From The Old Release’s History, Not This Diff) |
| Multi-Person GitOps/CI Environments | If CI Uses ArgoCD/Flux-Style Auto-Sync Tools On Production, The Same Logic Applies — Sync Tools Default To Pruning Anything Not In The Desired State, Same Risk Category As This Helm Incident. Be Extra Careful With Namespace-Level Resources When Setting Prune Allowlists/Excludes |
| Personal Side Projects Running Helm On A Small K8s Cluster | Small Scale Doesn’t Mean No Risk — This Incident’s Blast Radius Was 3 Pods + 1 DB Connection. Any Cluster Size Where A Chart Has Ever Touched The Rendering Condition Of A Top-Level Resource Needs The Same Prevention Routine |
Reference:
- Helm Upgrade — Using Helm
- Helm — Server-Side Apply
- Kubernetes — Server-Side Apply
- Real Incident: litellm-gke PRD Environment, 2026-06-29