Background: the app-of-apps pattern and how GitOps works on this cluster are covered in GitOps with ArgoCD — App-of-Apps Pattern.
The symptom: documentation that opens with a warning
For months, the reference file for my infrastructure started with this sentence:
⚠️ Read the “Deployment method” section first: not everything is GitOps. Editing the repo for a component managed by plain Helm does nothing, and conversely a manual
kubectl/helmon an ArgoCD component gets overwritten by selfHeal.
When a cluster’s documentation opens with a warning, that is not pedagogy — it is the symptom. On 5 September 2026, helm list -A on the control-plane node still returned nine releases:
NAME NAMESPACE REVISION CHART STATUS
traefik kube-system 3 traefik-40.1.3+up40.1.0 deployed
traefik-crd kube-system 5 traefik-crd-40.1.3+up40.1.0 deployed
grafana monitoring 6 grafana-10.5.15 deployed
vmagent monitoring 18 vmagent-0.41.2 deployed
vmsingle monitoring 9 victoria-metrics-single-… deployed
node-exporter monitoring 4 prometheus-node-exporter-… deployed
kube-state-metrics monitoring 2 kube-state-metrics-7.5.2 deployed
sealed-secrets kube-system 11 sealed-secrets-2.18.5 deployed
longhorn longhorn-system 7 longhorn-1.12.0 deployed
Two of those releases are legitimate: traefik and traefik-crd are managed by k3s itself through its bundled manifests mechanism. The other seven were pure debt — components installed by hand “for now”, several of them dating back to the original build of the cluster.
The cost of that debt is not theoretical. It is paid in seconds of hesitation, several times a week: to change vmsingle’s retention, do I edit the repo or run a helm upgrade? Getting the answer wrong one way does nothing at all; the other way, it gets overwritten by selfHeal three minutes later. Two sources of truth in one cluster is not twice the flexibility — it is one more question to ask before every move.
The context: ArgoCD does not “adopt” anything, it applies
One ambiguity has to be cleared up first, because it drives the whole method.
ArgoCD can consume a Helm chart, but it does not behave like the Helm client. It does not install a release: it runs the equivalent of helm template — the chart rendered into plain manifests — then applies those manifests to the cluster like any other YAML. It does not read the sh.helm.release.v1.* secrets that hold a release’s state, it does not create them, it does not update them.
In practice, “adopting” an existing Helm release means: creating an ArgoCD Application whose render lands exactly on the objects already present in the cluster. There is no ownership transfer, no migration command. There are two processes targeting the same objects, and you want the second one to produce precisely what the first one produced.
If the render is identical, applying it is a no-op: Kubernetes receives objects it already has, nothing restarts. If the render differs by even one field — a chart default that moved between two minor versions, a missing annotation — then the sync triggers a rollout. On a monitoring Deployment, that is an annoyance. On the storage layer serving volumes to the whole cluster, it is an incident.
Hence the pattern chosen, known as multiple-sources: an Application pointing at two sources at once — the official chart pinned at its publisher, and the GitOps repository providing the values file.
spec:
sources:
- repoURL: https://prometheus-community.github.io/helm-charts
chart: prometheus-node-exporter
targetRevision: 4.53.1
helm:
releaseName: node-exporter
valueFiles:
- $values/monitoring/vm/node-exporter-values.yaml
- repoURL: <internal-gitops-repo-url>
targetRevision: actual-state
ref: values
The second source deploys nothing: ref: values turns it into a plain file provider, referenced as $values by the first one. The upstream chart stays untouched — no vendoring, no fork — while the configuration lives versioned in the repository.
The method: five steps, three of them reversible
la version déjà déployée, pas la dernièrethe deployed version, not the latest
adopter et mettre à jour sont deux changements : jamais dans le même gesteadopting and upgrading are two changes: never in the same move
la config réelle, pas celle du READMEthe real config, not the README one
la source de vérité est la release en cours, pas le souvenir de son installationthe source of truth is the running release, not the memory of its install
le rendu doit être identique au vivantthe render must match what runs
une seule ligne de diff = une modification déguisée en migration : on corrige avanta single diff line means a change disguised as a migration: fix it first
Application sans automatedApplication without automated
le premier sync se regarde : zéro restart de pod est le critère de réussitethe first sync is watched: zero pod restarts is the pass criterion
commit dédié, après coupdedicated commit, afterwards
prune reste absent : une app adoptée ne doit jamais pouvoir supprimer son socleprune stays absent: an adopted app must never be able to delete its own base
point de non-retourpoint of no return① Same version, no exceptions
The temptation is real: since you are touching the component anyway, why not move to the latest chart version? It has to be resisted completely. Adopting and upgrading are two different kinds of change, and mixing them makes diagnosis impossible — if a pod restarts, was it the adoption or the bump?
So the target version is the one helm list -A already shows, down to the patch: 4.53.1 for node-exporter, 7.5.2 for kube-state-metrics, 10.5.15 for grafana, 2.18.5 for sealed-secrets, 1.12.0 for the Longhorn chart. The bump comes later, as an ordinary commit — which is precisely the point of the exercise.
② The live values, not the README ones
The configuration to commit is the one actually applied to the running release, not the one you believe you applied at install time:
helm get values kube-state-metrics -n monitoring
On this cluster, three of the seven components already had their values in the repository, inherited from the initial install. The check was worth doing anyway: for grafana, vmagent and vmsingle, the repo file and the live release matched exactly, across eighteen vmagent revisions. For the others, the extraction produced short and honest files:
# kube-state-metrics values — consumed by the ArgoCD Application
# Source: helm get values kube-state-metrics -n monitoring (release rev 2)
resources:
limits:
cpu: 200m
memory: 96Mi
requests:
cpu: 20m
memory: 32Mi
③ The pre-adoption diff must be empty
This is the check that carries the entire safety of the operation, and it fits on one line:
helm template <release> <repo>/<chart> --version <X> \
-f values.yaml -n <namespace> | kubectl diff -f -
You render the chart exactly as ArgoCD will render it, and compare against the live cluster. The expected output is empty. Not “short”, not “no significant change”: empty.
Any line appearing here is a change disguised as a migration. It has to be understood and made to disappear — by adjusting the values, by fixing the version — before going further. Across the seven components, the diff was empty on the first attempt every time, including on the bulky ones: ten resources for sealed-secrets, forty-two for the Longhorn chart (CRDs included), five for kube-state-metrics of which a ClusterRole and its binding.
Two adjustments were needed to get that result. The render must use --no-hooks on charts that ship lifecycle jobs, otherwise kubectl diff offers to create objects that have no reason to exist in steady state. And the Application must carry ServerSideApply=true: without it, ArgoCD compares partial objects on large resources such as the Longhorn CRDs and sees differences that are not real.
④ Manual sync, no automated
The Application is created without an automated block. The first sync is triggered by hand, and it is watched:
# Adoption: manual sync first; automated/selfHeal enabled after the first
# sync verified healthy (dedicated commit). prune deliberately absent.
syncPolicy:
syncOptions:
- ServerSideApply=true
The pass criterion is not “ArgoCD shows Synced”. It is zero pod restarts: six node-exporter pods untouched, no restart; the sealed-secrets controller still alive and kubeseal --fetch-cert working; and for Longhorn, 14 volumes out of 14 still healthy throughout the operation, 6 managers and their instance-managers unchanged.
⑤ selfHeal, in a separate commit
automated: { selfHeal: true } only comes afterwards, in a dedicated commit whose message documents the validation:
# Adoption validated: pre-adoption diff empty, first sync Succeeded
# with no pod restart. prune deliberately absent (app adopted from Helm).
syncPolicy:
automated:
selfHeal: true
syncOptions:
- ServerSideApply=true
Two absences are deliberate and permanent.
prune stays disabled on all seven applications. An app adopted from Helm must never be allowed to delete objects: if a values mistake makes a resource drop out of the render, you want a visible drift, not a silent deletion.
The finalizer is absent on sealed-secrets and on the Longhorn chart. By default you add resources-finalizer.argocd.argoproj.io so that deleting an Application cleans up its resources. On these two, that is exactly what you do not want: an accidental deletion of the Application must never cascade onto the encryption controller or onto the storage layer.
The traps: what was not in the plan
The chart repository that moved
The sealed-secrets adoption worked, then broke a few minutes later. The chart publisher had migrated its repository from bitnami-labs.github.io to bitnami.github.io in June 2026 — and GitHub Pages does not redirect: the old URL returns a flat 404.
The Helm client had never flagged it, because it was serving the chart from its local cache. ArgoCD, on the other hand, resolves the repository on every sync. A Helm release installed from a dead URL can live for months without anyone noticing; the GitOps adoption is what makes the breakage visible. That is an argument for the migration rather than against it.
Longhorn: the job that assumes a Helm cycle
The Longhorn chart ships a pre-upgrade-checker, a job that checks system state before a version bump. It assumes a classic Helm lifecycle and fails under ArgoCD — the publisher in fact documents disabling it as a prerequisite for GitOps installs:
preUpgradeChecker:
jobEnabled: false
The chart’s other hooks cope with the ArgoCD model: the post-upgrade job is idempotent and runs as PostSync, the pre-delete hook is simply ignored. And one constraint survives the adoption intact: Longhorn version bumps must be strictly sequential, never skipping a minor version. Going through ArgoCD changes nothing about that rule — it has just become easier to break, since bumping targetRevision is a one-character edit.
The PVC that belongs to nobody
vmsingle is a StatefulSet, and its volume is created by a volumeClaimTemplate. Those PVCs are generated by the StatefulSet controller, not by the chart: they stay outside the scope ArgoCD manages. Not a problem in itself, but worth knowing before wondering why the metrics database’s storage appears nowhere in the Application tree.
A filename forbidden by its own safeguard
A trivial detail, but one that costs five minutes: the sealed-secrets Application manifest could not be named sealed-secrets.yaml. The repository carries a .gitignore rule on *secrets.yaml — an anti-leak safeguard installed much earlier, which did exactly its job on a perfectly harmless file. The file is called sealed-secrets-app.yaml.
The check that does not show up in the diff
The sealed-secrets controller’s private key — the one that decrypts every secret in the repository — is not a chart resource. It is generated by the controller on first start and lives in a cluster Secret. No diff, no Helm render, no command output would have flagged it.
Yet it is the one point in the operation where a mistake would have been irreversible: losing that key means losing the ability to decrypt every versioned secret. So the break-glass backup was verified in the password manager before touching the Application. An adoption checklist is not limited to what the tools know how to compare.
The result, and the point of no return
After the seven adoptions:
$ helm list -A
NAME NAMESPACE REVISION CHART STATUS
traefik kube-system 3 traefik-40.1.3+up40.1.0 deployed
traefik-crd kube-system 5 traefik-crd-40.1.3+up40.1.0 deployed
Only the two k3s-managed releases remain. The cluster went from 24 to 31 ArgoCD Applications, all Synced and Healthy, and the warning at the top of the documentation has been replaced by an affirmative sentence.
It is worth being clear about what was lost along the way. The Helm release secrets are gone: helm list no longer knows about these components, helm upgrade will not work on them, and neither will helm rollback. The helm binary now serves only helm template for debugging. That is the point of no return in step ⑤ — accepted, but real: the only way to update these components is now to change targetRevision in the repository and push.
One last, less comfortable observation: the grafana/grafana chart is marked deprecated upstream. The 10.5.15 pin holds, but the GitOps adoption did not make that particular debt go away — it merely made it readable in a versioned file rather than in the memory of whoever ran the helm install. Which is about all you can ask of a migration: not to solve the underlying problems, but to stop hiding them.
Runbook — adopting a Helm release into ArgoCD
- Record what exists:
helm list -Afor the exact chart version,helm get values <rel> -n <ns>for the configuration actually applied. - Check what the diff will not see: keys, data generated by the component itself, break-glass backups. Do it before, not after.
- Commit the values to the GitOps repository, with a header comment citing their provenance and date.
- Write the Application in multiple-sources form,
targetRevisionat the same version,ServerSideApply=true, noautomated, noprune. No finalizer on components whose deletion would be catastrophic. - Demand an empty diff:
helm template … --no-hooks | kubectl diff -f -. Any output line is a stop. - Sync by hand and check for the absence of pod restarts, not just the
Syncedstatus. - Enable
selfHealin a separate commit, once the sync is validated, documenting the verification in the commit message. - One component at a time. Seven adoptions in one session is fine; seven adoptions in one commit is not — when something moves, you need to know what.