CoursesFluxHelmRelease & the helm-controller

HelmRelease & the helm-controller

Charts, the GitOps way.

Advanced14 min · lesson 5 of 12

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.

podinfo-helmrelease.yaml
apiVersion: source.toolkit.fluxcd.io/v1
kind: HelmRepository
metadata:
name: podinfo
namespace: flux-system
spec:
interval: 1h # how often to refresh the chart index
url: https://stefanprodan.github.io/podinfo
---
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: podinfo
namespace: podinfo
spec:
interval: 10m # reconcile cadence for this release
chart:
spec:
chart: podinfo
version: "6.7.x" # narrow, reviewed range, not ">=6.0.0"
sourceRef:
kind: HelmRepository
name: podinfo
namespace: flux-system
values:
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.

podinfo-helmrelease-oci.yaml
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
name: podinfo
namespace: podinfo
spec:
interval: 10m
chartRef: # pull the chart straight from an OCI registry
kind: OCIRepository
name: podinfo
install:
remediation:
retries: 3 # retry a failed first install
upgrade:
remediation:
retries: 3
remediateLastFailure: true # roll back if the last retry still fails
driftDetection:
mode: enabled # revert manual kubectl edits (off by default)
valuesFrom:
- kind: ConfigMap
name: podinfo-values
valuesKey: values.yaml
values:
replicaCount: 2 # inline values merge OVER valuesFrom
One reconcile of a HelmRelease
1Git holds the HelmRelease
chart, version, values
2source-controller fetches the chart
pins the resolved version as a HelmChart artifact
3helm-controller merges values
valuesFrom first, inline values on top
4Render and diff
compare against the last release
5Helm install or upgrade
writes a helm.sh/release.v1 Secret
6Drift detection loop
reverts manual edits if enabled

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.

terminal
flux get helmreleases -A
output
NAMESPACE NAME REVISION SUSPENDED READY MESSAGE
podinfo podinfo 6.7.1 False True Helm upgrade succeeded for release podinfo/podinfo.v2 with chart [email protected]
terminal
flux reconcile helmrelease podinfo -n podinfo --with-source
output
► 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.

terminal
helm history podinfo -n podinfo
helm get values podinfo -n podinfo
output
REVISION UPDATED STATUS CHART APP VERSION DESCRIPTION
1 Mon Jul 20 09:14:02 2026 superseded podinfo-6.7.0 6.7.0 Install complete
2 Mon Jul 20 10:02:47 2026 deployed podinfo-6.7.1 6.7.1 Upgrade complete
USER-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.

podinfo-ocirepository.yaml
apiVersion: source.toolkit.fluxcd.io/v1
kind: OCIRepository
metadata:
name: podinfo
namespace: podinfo
spec:
interval: 10m
url: oci://ghcr.io/stefanprodan/charts/podinfo
ref:
tag: 6.7.1 # an exact tag, reviewed before it lands
verify:
provider: cosign # reject any chart not signed by this key
secretRef:
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.

terminal
kubectl get secret -n podinfo -l owner=helm
output
NAME TYPE DATA AGE
sh.helm.release.v1.podinfo.v1 helm.sh/release.v1 1 56m
sh.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.

Ready does not mean current
A HelmRelease reporting Ready means the last reconcile succeeded, not that your most recent edit is live. Changes to a 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.
Quick check
01You edit the 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?
Incorrect — READY True reports that the last reconcile finished cleanly. A crashed controller would freeze the status and fill its logs with errors, and neither symptom is present here.
Incorrect — Inline values win only where the same key appears in both places. Any key that lives only in the ConfigMap still reaches the chart, so precedence is not what is holding this change back.
Correct — There is no watch on that object, so your edit waits out the interval: 10m set on the release. Run flux reconcile helmrelease podinfo -n podinfo when you need the value applied straight away.
Incorrect — Drift detection works on the resources the chart renders, not on the inputs you feed it, so a ConfigMap you edit by hand is outside what it compares.
02A teammate argues that 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?
Correct — Pinning 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.
Incorrect — Ranges resolve without trouble, and the OCI path in this lesson pins by tag through chartRef instead. The problem is that ranges work exactly as written, not that they break.
Incorrect — The 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.
Incorrect — Signature checking lives on the source object, under spec.verify with the cosign provider. It applies or it does not, independent of how you write the version field.
03An attacker with cluster access runs 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?
Incorrect — That automatic correction is what a Kustomization gives you, and what a HelmRelease gives you only once driftDetection.mode: enabled is set. Without it the controller never compares live objects to the chart.
Incorrect — That column reflects the outcome of the last Helm operation, not the current shape of the Deployment. With no comparison running, nothing exists to flip it.
Incorrect — The 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.
Correct — The controller acts on changed inputs, and an out-of-band edit is not an input. Setting 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.

Related