HelmRelease & the helm-controller
Charts, the GitOps way.
Helm is the package manager for Kubernetes, the same idea as apt on Debian or the App Store on your phone. Someone bundles up an application once, and you install it by name instead of copying dozens of manifests (the plain-text YAML files that tell Kubernetes what to run) by hand. That bundle is a chart: a folder of that same YAML with knobs on it, so one chart can run as a one-replica test or a fifty-replica production system. You turn the knobs with values.
Running helm install from your laptop is fine on day one. It stops being fine the moment someone asks a plain question: what version is actually running in production right now? Often nobody knows, because the answer lived in a shell history that scrolled away weeks ago. Re-run the same command with one flag out of place and you get a subtly different cluster. A HelmRelease turns that live-fired command into a written standing order. You describe the chart, the exact version, and the values as a Kubernetes object, commit it to Git, and a program called the helm-controller runs Helm for you, again and again, from inside the cluster, keeping a full history of every release. The chart becomes desired state instead of a command someone half-remembers typing at 2am.
A HelmRelease Is a Committed helm install
A HelmRelease is a small YAML object that points at a chart and says which values to apply. The chart itself lives elsewhere, in a source. The most common source is a HelmRepository, which is the classic chart repository: a web server hosting an index.yaml that lists every chart and version it holds. Your HelmRelease references that repository through chart.spec.sourceRef, names the chart, and gives a version. Everything under values is the same YAML you would hand to helm install -f. If you already know Helm, you already know this file. You are moving the flags out of your fingers and into Git, where they can be reviewed.
Two details carry weight. The version field takes a semver range (semantic versioning, the MAJOR.MINOR.PATCH scheme like 6.7.1), and the controller resolves that range against the repository index on every reconcile (each pass of its check-and-correct loop). How tightly you pin it decides when an upgrade happens, which is a security decision as much as an operational one. Second, the release and its source can sit in different namespaces (a namespace is a named partition inside a cluster that keeps one team's resources apart from another's). Here the repository lives in flux-system while the release deploys into its own podinfo namespace, the normal shape for a cluster shared between teams.
apiVersion: source.toolkit.fluxcd.io/v1kind: HelmRepositorymetadata:name: podinfonamespace: flux-systemspec:interval: 1h # how often to refresh the chart indexurl: https://stefanprodan.github.io/podinfo---apiVersion: helm.toolkit.fluxcd.io/v2kind: HelmReleasemetadata:name: podinfonamespace: podinfospec:interval: 10m # reconcile cadence for this releasechart:spec:chart: podinfoversion: "6.7.x" # narrow, reviewed range, not ">=6.0.0"sourceRef:kind: HelmRepositoryname: podinfonamespace: flux-systemvalues:replicaCount: 2 # same YAML you'd pass to `helm install -f`
How the Helm-Controller Reconciles
Think of the helm-controller as a diligent clerk who re-reads your standing order at a fixed interval. Every interval (ten minutes in the example) it loads the chart artifact that the source-controller has already fetched at the resolved version, merges your values on top, and renders the final manifests. Then it compares. If nothing meaningful changed, it does nothing. On the first run it performs a real Helm install. When the rendered result differs from the last release, because you bumped the version or edited a value, it performs a real Helm upgrade.
Underneath, this is ordinary Helm. The controller stores each release the same way the Helm command-line tool does, as a Kubernetes Secret (an object built to hold small, sensitive pieces of data) of type helm.sh/release.v1. So your normal Helm tooling keeps working against a Flux-managed release: helm history shows every revision, helm get values shows exactly what was applied. Flux owns the desired state, Helm owns the release record, and they meet at that Secret. Hold that split in your head and debugging gets much easier.
The install and upgrade blocks add remediation, which is what a good clerk does when an order goes wrong: retry, and if it still fails, put things back. retries: 3 gives a failed rollout three more attempts. remediateLastFailure: true rolls back to the last good release when the final attempt still fails, instead of leaving a half-applied, wedged deployment for the on-call engineer to untangle. You can also pull the chart straight from an OCI registry (Open Container Initiative, the same registry format that stores container images) with chartRef pointing at an OCIRepository, and layer valuesFrom a ConfigMap or Secret (a ConfigMap is a Kubernetes object that holds ordinary configuration data) beneath your inline values, which win on conflict.
apiVersion: helm.toolkit.fluxcd.io/v2kind: HelmReleasemetadata:name: podinfonamespace: podinfospec:interval: 10mchartRef: # pull the chart straight from an OCI registrykind: OCIRepositoryname: podinfoinstall:remediation:retries: 3 # retry a failed first installupgrade:remediation:retries: 3remediateLastFailure: true # roll back if the last retry still failsdriftDetection:mode: enabled # revert manual kubectl edits (off by default)valuesFrom:- kind: ConfigMapname: podinfo-valuesvaluesKey: values.yamlvalues:replicaCount: 2 # inline values merge OVER valuesFrom
Inspect and Drive a Release
Because it is standard Helm underneath, you debug with both toolkits. flux get helmreleases -A shows every release, its last applied revision, and whether it is Ready. When you do not want to wait out the interval, flux reconcile forces an immediate run, and --with-source refreshes the chart index first, so you pick up a version published a minute ago instead of reconciling against a stale index.
flux get helmreleases -A
NAMESPACE NAME REVISION SUSPENDED READY MESSAGEpodinfo podinfo 6.7.1 False True Helm upgrade succeeded for release podinfo/podinfo.v2 with chart [email protected]
flux reconcile helmrelease podinfo -n podinfo --with-source
► annotating HelmChart podinfo-podinfo in flux-system namespace✔ HelmChart annotated◎ waiting for HelmChart reconciliation✔ HelmChart reconciliation completed✔ fetched revision 6.7.1► annotating HelmRelease podinfo in podinfo namespace✔ HelmRelease annotated◎ waiting for HelmRelease reconciliation✔ HelmRelease reconciliation completed✔ applied revision 6.7.1
When a release looks wrong, drop to the Helm CLI (command-line interface) against the same data. The revision history and the exact rendered values are right there, written by the controller.
helm history podinfo -n podinfohelm get values podinfo -n podinfo
REVISION UPDATED STATUS CHART APP VERSION DESCRIPTION1 Mon Jul 20 09:14:02 2026 superseded podinfo-6.7.0 6.7.0 Install complete2 Mon Jul 20 10:02:47 2026 deployed podinfo-6.7.1 6.7.1 Upgrade completeUSER-SUPPLIED VALUES:replicaCount: 2
Pin the Version, Verify the Chart, Pick the Identity
A wide version range is a supply-chain hole. It is a standing order to accept whatever the newest chart happens to be, sight unseen. Write version: ">=6.0.0" and you have told the controller to install whatever the newest chart at or above 6.0.0 is on the next reconcile. If an attacker gains publish access to that chart repository, or a maintainer's account is compromised, their next release walks into your cluster the moment the index refreshes, with no human in the loop. Pin an exact version, or a narrow patch range like 6.7.x you have reviewed, and every bump becomes a Git commit somebody approves.
Pinning stops silent upgrades, but it does not prove the chart is the one the vendor actually built. For that, pull charts from an OCI registry and have Flux check the signature before it installs anything. On an OCIRepository you set spec.verify with the cosign provider (cosign is the Sigstore project's tool for signing and verifying artifacts) and point it at the publisher's public key. A chart that fails verification is never rendered, so a swapped or tampered artifact stops at the door instead of reaching your workloads.
apiVersion: source.toolkit.fluxcd.io/v1kind: OCIRepositorymetadata:name: podinfonamespace: podinfospec:interval: 10murl: oci://ghcr.io/stefanprodan/charts/podinforef:tag: 6.7.1 # an exact tag, reviewed before it landsverify:provider: cosign # reject any chart not signed by this keysecretRef:name: podinfo-cosign-pub
One control people miss: identity. By default the helm-controller applies your manifests using its own ServiceAccount (the in-cluster identity a controller runs as, the way a program on a Linux box runs under a user account), which is usually powerful enough to create almost anything in the cluster. In a shared cluster that means a HelmRelease in one team's namespace could deploy resources it has no business touching. Set spec.serviceAccountName on the HelmRelease and the controller impersonates that ServiceAccount instead, so the release can only do what that account's RBAC (role-based access control, Kubernetes' permission system) permits. It is the difference between letting every recipe run as the head chef and making each one run as the cook who owns that station.
Build one more habit: know where the release records live and who can read them. They are Secrets in the release namespace, labelled owner=helm.
kubectl get secret -n podinfo -l owner=helm
NAME TYPE DATA AGEsh.helm.release.v1.podinfo.v1 helm.sh/release.v1 1 56msh.helm.release.v1.podinfo.v2 helm.sh/release.v1 1 12m
Anyone who can read Secrets in that namespace can read the full rendered release, values and all. That is a good reason to keep sensitive settings in dedicated Secrets with tight access rather than inline in the HelmRelease.
The Quiet Gaps Where Drift Hides
The controller re-runs Helm only when its inputs change: a new chart version, or edited values. By default it does not notice that someone ran kubectl edit on the Deployment it created. Say an attacker already has a foothold and quietly patches your running Deployment, adding a sidecar container that mines cryptocurrency or swapping the image for a backdoored build. Nothing in Flux blinks. The manual change survives untouched until the next chart or values change forces an upgrade, which could be weeks away. Turn on driftDetection.mode: enabled and the story changes: on every reconcile the controller compares the live objects against what the chart says they should be and reverts anything that does not match. The correction lands in the controller's events and logs, so you get a detection signal on top of the repair.
The second gap is quieter still. Editing a ConfigMap referenced by valuesFrom does not, by itself, trigger an upgrade. The controller does not watch that ConfigMap. It re-reads it on its own interval. So a value you changed can sit unapplied for minutes while the HelmRelease cheerfully reports Ready, and everyone assumes the new setting is live when it is not. If a change must take effect now, edit the source and run flux reconcile helmrelease, rather than trusting the next tick.
valuesFrom ConfigMap or Secret are picked up only on the next interval, and with drift detection off, a manual kubectl edit to a managed resource is never reverted at all. Pin versions, turn on drift detection where integrity matters, and force a reconcile when you need a value applied immediately instead of assuming it already is.podinfo-values ConfigMap that the HelmRelease reads through valuesFrom, then check ten seconds later. flux get helmreleases -A still shows READY True and the pods are unchanged. What explains it best?interval: 10m set on the release. Run flux reconcile helmrelease podinfo -n podinfo when you need the value applied straight away.version: ">=6.0.0" is safer than the 6.7.x in the example because it always picks up security patches. Why does the lesson treat the wide range as the bigger risk?6.7.1, or a 6.7.x range you have reviewed, turns every version bump into a commit somebody approves. A wide floor hands that decision to whoever holds publish rights on the repository.chartRef instead. The problem is that ranges work exactly as written, not that they break.interval: 10m field sets cadence, and it holds whether you accept one version or fifty. Range width decides which chart gets chosen, not how often the loop runs.spec.verify with the cosign provider. It applies or it does not, independent of how you write the version field.kubectl edit on the Deployment that the podinfo HelmRelease created and adds a mining sidecar. The HelmRelease has no driftDetection block. What plays out over the following weeks?driftDetection.mode: enabled is set. Without it the controller never compares live objects to the chart.retries: 3 and remediateLastFailure: true settings fire when Helm itself fails an install or upgrade. Helm attempted nothing here, so there is no failure for that logic to answer.driftDetection.mode: enabled makes every pass put the Deployment back and write the correction into events and logs.When a release looks wrong, don't guess from the outside. Run flux reconcile helmrelease <name> -n <ns> --with-source to force a fresh pass against the newest chart, then read the record with helm history. Both commands are looking at the same helm.sh/release.v1 Secret, so they cannot tell you different stories: if Flux reports it applied 6.7.1 while Helm's history still ends at 6.7.0, you have found exactly where the pipeline stalled.
Try this
Run flux get helmreleases -A on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.
Takeaway
The trap worth remembering here: ready does not mean current. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.