Helm Chart Management: Centralized Repos vs Per-Service Charts (and the Model That Actually Wins)

Helm Chart Management: Centralized Repos vs Per-Service Charts (and the Model That Actually Wins)

Every platform team hits the same wall at roughly the same size. Somewhere between service number 20 and service number 60, the Helm charts stop being “a few YAML files” and become a system of their own: dozens of copies of the same Deployment template, three different ways of declaring a ServiceAccount, ingress annotations that drifted apart eighteen months ago, and nobody who can say with confidence which services still run the old securityContext. The question that follows is always framed the same way — should we centralize all charts in one repo, or let every team own theirs? — and it is the wrong question, because the two answers it allows are both bad at scale.

This article is about the third option, the one most mature organisations converge on whether they planned to or not: a versioned library chart that owns the opinions, thin per-service charts that own the intent, and an OCI registry as the single distribution channel. It compares that model honestly against the two pure ones, shows the code, and ends with a decision framework and a migration path for teams currently sitting on a central chart monorepo.

The Short Answer

If you have more than a handful of services and a platform team of any size, do this:

  • Put the how — Deployment shape, probes, security context, labels, pod disruption budgets, ServiceMonitor, HPA defaults — in one library chart (type: library) that is semantically versioned and published to an OCI registry.
  • Give each service a thin application chart that lives next to its code, declares the library chart as a dependency with a range like ~1.4.0, and contains almost nothing but values.yaml and a couple of one-line templates that include the library’s named templates.
  • Publish every packaged chart to the same OCI registry you already use for images, and point Argo CD or Flux at that registry, not at Git.

Centralize the opinions, distribute the ownership, unify the distribution. Everything below is the reasoning and the mechanics.

Model 1: The Centralized Chart Repository

One repository — usually platform/helm-charts — holds every chart in the organisation, and the platform team reviews every change. It is the natural first move for a team that just watched three application squads implement ingress three different ways, and it fixes that problem quickly.

What it gets right is standardisation: one CI pipeline runs helm lint --strict, helm unittest and kubeconform on every chart; a single CODEOWNERS line puts the platform team on every PR; and when a new cluster policy lands, the people who wrote the policy also write the fix. Discovery is trivial (there is only one place to look) and shared subcharts are just directories.

What it gets wrong only shows up later, and it is structural rather than fixable with more process:

  • The platform team becomes the deploy queue. Every values tweak an application team needs — a new env var, a sidecar, a memory bump — is a PR into a repo they do not own, reviewed by people who do not know the service. The review is either rubber-stamped (so why require it?) or slow (so teams route around it).
  • The chart drifts from the code. A service’s deployment definition lives in a different repository from the service, versioned on a different cadence, with a different release process. A feature branch that needs a new ConfigMap key cannot be tested end to end without a cross-repo change.
  • Blast radius is the whole org. A change to a shared helper that is used by 200 charts is a change to 200 services, and the monorepo makes that easy to do by accident — one _helpers.tpl edit, one merge, and every next deploy picks it up.

The centralised model optimises for the platform team’s control at the exact moment the organisation needs the platform team to stop being the bottleneck.

Model 2: One Chart Per Service

The mirror image: each service repository contains charts/<service>/, the team that owns the code owns the chart, and the platform team publishes guidance rather than reviewing changes.

This is what most teams want culturally, and for good reasons. The chart is versioned with the application, a feature branch can change the manifest and the code in one PR, a single pipeline builds the image and packages the chart, and a broken chart breaks one service. Ownership is unambiguous.

The failure mode is drift, and it is inevitable rather than hypothetical. Charts are created by copying the last one, so the organisation ends up with forty forks of the same Deployment template, each frozen at whatever the copied chart looked like that quarter. When the platform team needs seccompProfile: RuntimeDefault on every pod, or a new mandatory label for cost allocation, or a probe timeout change, the work is forty PRs into forty repositories owned by forty teams with forty different priorities. Quality is a function of each team’s Helm skill, which varies enormously. Security review has to happen at admission time (Kyverno, Gatekeeper) because it cannot happen at chart time.

Per-service charts optimise for team autonomy at the cost of any ability to change the platform at scale.

Side-by-Side

ConcernCentralized repoPer-service chartsLibrary + thin charts
Who owns a service’s chartPlatform teamService teamService team (values), platform team (templates)
Standard changes across 200 servicesOne PR, 200 blast radius200 PRsOne library release + automated version bumps
Chart lives with the codeNoYesYes
Quality gatesCentral CIPer-repo, variesCentral CI on the library, template CI on services
Drift between servicesLowHighLow
Platform team as bottleneckYesNoNo
Skill required from service teamsLowHighLow
Breaking change containmentPoorGoodGood (semver range)

The third column is the rest of this article.

Model 3: A Library Chart Plus Thin Per-Service Charts

Helm has had a first-class mechanism for exactly this split since Helm 3: the library chart. A chart with type: library in its Chart.yaml cannot be installed and renders no manifests of its own; it exists only to be pulled in as a dependency so other charts can include its named templates. Unlike a regular subchart, a library chart’s templates run with the parent’s .Values and .Files, which is what makes it usable as a template engine rather than as a bundled application.

The platform team publishes one such chart — call it mycompany/common — and it owns every opinion the organisation has about how a workload should look on its clusters. A service chart then becomes remarkably small.

Chart.yaml of a service:

apiVersion: v2
name: payments-api
description: Payments API
type: application
version: 3.12.0
appVersion: "2026.09.1"
dependencies:
  - name: common
    version: "~1.4.0"
    repository: "oci://registry.mycompany.io/charts"

templates/deployment.yaml of that service:

{{- include "common.deployment" . }}

templates/service.yaml, templates/hpa.yaml, templates/servicemonitor.yaml are each a single include line as well. The service’s values.yaml is where the team spends its time:

image:
  repository: registry.mycompany.io/payments/api
  tag: "2026.09.1"
replicas: 3
resources:
  requests: { cpu: 250m, memory: 512Mi }
  limits:   { memory: 512Mi }
env:
  PAYMENT_PROVIDER: stripe
ingress:
  host: payments.internal.mycompany.io

Inside the library, common.deployment is a named template that renders the Deployment with the security context, labels, probes and topology spread the platform team decided on, reading only the values the service exposes. The Helm docs’ pattern for this is a two-step merge — a _tpl template holding the defaults and a wrapper that merges the caller’s overrides on top — so a team that genuinely needs a non-standard field can set it in values without forking the template:

{{- define "common.deployment.tpl" -}}
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "common.fullname" . }}
  labels: {{- include "common.labels" . | nindent 4 }}
spec:
  replicas: {{ .Values.replicas | default 2 }}
  template:
    spec:
      securityContext:
        runAsNonRoot: true
        seccompProfile: { type: RuntimeDefault }
      containers:
        - name: {{ .Chart.Name }}
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
{{- end }}

{{- define "common.deployment.overrides" -}}
{{- /* empty by default; a service redefines this template to override fields */ -}}
{{- end }}

{{- define "common.deployment" -}}
{{- include "common.util.merge" (list . "common.deployment.overrides" "common.deployment.tpl") }}
{{- end }}

Template names are global in Helm and the parent chart’s templates are loaded after its dependencies, so a service that needs, say, a custom terminationGracePeriodSeconds redefines common.deployment.overrides in its own _helpers.tpl with just that fragment, and the merge lays it over the platform default. Services that need nothing leave it alone.

Two things fall out of this structure that neither pure model can offer.

Standard changes become a release, not a campaign. When the platform team needs every pod to carry a new cost-allocation label, they change one template, cut common 1.5.0, and every service picks it up on its next dependency bump. No forty PRs, no hunting through forks.

Breaking changes are contained by semver. If the platform team has to rename a value or drop a field, that is common 2.0.0, and no service declaring ~1.4.0 will ever pull it by accident. Teams migrate on their own schedule, and the platform team can support 1.x with patch releases while they do. The constraint syntax is doing real governance work here: ~1.4.0 allows 1.4.x patches only, ^1.4.0 allows any 1.x including new features, and a hard pin allows nothing — see Helm version constraints explained for the exact bounds of each operator and the 0.x caret trap that catches library charts still on a 0. major. The mechanics of Chart.lock and helm dependency update are covered in Helm dependencies.

Versioning the library honestly

The library chart is the one artifact in this model whose version number carries organisational weight, so be strict with it:

  • Patch (1.4.3): a template fix that renders identically for every existing consumer, or a change to a default that no service could observe.
  • Minor (1.5.0): a new optional value, a new named template, a new default that services can opt out of.
  • Major (2.0.0): any renamed or removed value, any rendered output change that a consumer could not have opted out of, any change to the merge contract.

If you are not sure whether a change is minor or major, it is major. The cost of an unnecessary major is a version bump PR; the cost of a wrong minor is a fleet-wide surprise at deploy time.

Distribution: OCI Is the Default Now

Where charts are developed and where they are published are separate decisions, and conflating them is how teams end up serving index.yaml from a Git branch or a ChartMuseum nobody wants to run.

Since Helm 3.8 the oci:// scheme has been stable, and it is the right answer for almost everyone: charts go into the same registry as images (Harbor, ECR, ACR, GHCR, Artifact Registry), with the same authentication, replication, retention and vulnerability-scanning pipeline already in place. The classic HTTP repository with its regenerated index.yaml still works, but it is a second piece of infrastructure to run, its index can go stale relative to the packages, and it has no notion of immutable digests. New projects should not start there.

Publishing is two commands:

helm package charts/common          # → common-1.5.0.tgz
helm push common-1.5.0.tgz oci://registry.mycompany.io/charts

Note that helm push takes the namespace only — the chart name and tag are taken from Chart.yaml. Consumers reference it in dependencies: exactly as in the payments-api example above, and can install or template it directly:

helm install payments oci://registry.mycompany.io/charts/payments-api --version 3.12.0
# or pinned by digest for supply-chain guarantees
helm install payments oci://registry.mycompany.io/charts/payments-api@sha256:9f2c...

Helm 4 (released November 2025) does not change the OCI story — the commands and the oci:// dependency syntax are unchanged — but two of its features are relevant to chart management at scale. Reproducible chart archives mean helm package on the same tree produces the same bytes, which makes “has this chart actually changed?” answerable in CI. And content-addressed local caching of charts makes a pipeline that resolves a library dependency for 200 services materially cheaper. Neither is a reason to migrate on its own; both are nice once you are there.

Governance Without a Bottleneck

The library model moves the platform team’s review effort to where it has leverage: the library itself, and the CI templates every service runs.

On the library chart, be paranoid. It is the only chart whose bugs ship to everyone:

  • helm lint --strict and helm unittest against a matrix of representative consumer values — the library has no values of its own, so tests must exercise it through a fixture chart.
  • Render the fixture with helm template and validate with kubeconform against every Kubernetes minor you run.
  • Ship a values.schema.json for the values the library expects consumers to provide, so a typo in a service’s values.yaml fails at helm install rather than at runtime; the pattern and its limits are in Helm values schema validation.
  • CODEOWNERS on the library repo puts the platform team on every change. That is the one place where central review is worth the latency.

On service charts, keep the gate light and automated: a shared CI template that runs lint, helm template | kubeconform, and a diff against the currently deployed release. Helm chart testing best practices covers the tooling; the point here is that the service team owns the pipeline run and the platform team owns the pipeline template.

For fleet-wide changes, the mechanism is dependency automation, not PR campaigns. Renovate and Dependabot both understand Helm Chart.yaml dependencies, including oci:// repositories. Configure them to open a PR on every service repo whenever common cuts a release within the service’s declared range, with auto-merge for patch versions if the CI gate passes. A platform change then becomes: cut common 1.5.0, watch the bots do the rounds, chase the handful of repos whose CI failed. That is the difference between a two-day change and a two-quarter one.

Admission policy still belongs in the cluster. Kyverno or Gatekeeper is the safety net for the chart a team wrote without the library, or the Deployment somebody applied by hand. The library makes compliance the default; the admission controller makes non-compliance impossible. You want both.

How It Fits GitOps

Argo CD and Flux both consume charts from OCI registries directly, which is what makes the “publish everything to the registry” rule pay off — the GitOps repository holds which version of which chart with which values, not the chart source.

With Flux, the service’s HelmRelease points at an OCIRepository (or a HelmRepository with type: oci):

apiVersion: source.toolkit.fluxcd.io/v1
kind: OCIRepository
metadata:
  name: payments-api
  namespace: payments
spec:
  interval: 10m
  url: oci://registry.mycompany.io/charts/payments-api
  ref:
    semver: "~3.12.0"
---
apiVersion: helm.toolkit.fluxcd.io/v2
kind: HelmRelease
metadata:
  name: payments-api
  namespace: payments
spec:
  interval: 10m
  chartRef:
    kind: OCIRepository
    name: payments-api
  values:
    replicas: 5

Argo CD’s equivalent is an Application whose source.repoURL is the registry (no oci:// prefix in Argo’s case, and the repo must be registered with enableOCI: true) and whose source.chart and targetRevision select the chart and version.

Now separate the two decisions that are usually mashed together:

  • *Where chart source lives* — monorepo or per-service repo — is a question about ownership and review, answered above: library in the platform repo, service charts next to the service.
  • *Where charts are published*** is a question about distribution, and the answer is always “the one OCI registry”, regardless of where the source came from.

Teams that skip the publishing step and point Argo CD at chart directories in Git get a system where the deployed artifact has no version, no digest and no scan, and where “what is running in production?” is answered by a commit SHA in a repo that also contains the source of forty other charts. Publish the chart. It is one CI step.

Decision Framework

How many services deploy with Helm?
│
├── < 10, one team, no platform function
│   └── Per-service charts. Copy a good template, revisit at 15+.
│
├── 10–40, a platform team exists or is forming
│   ├── Is drift already hurting (security context, labels, probes)?
│   │   ├── YES → Library chart now; migrate the worst offenders first.
│   │   └── NO  → Per-service charts + a shared CI template; start the library
│   │             when the third team copies the second team's chart.
│   └── Never a central chart repo: it is a bottleneck at this size and
│       a migration at the next one.
│
├── 40+, multiple product teams
│   └── Library chart + thin service charts + OCI + Renovate. Not optional.
│       A central chart repo here is the platform team's entire backlog.
│
└── Regulated, or platform team must approve every deployment change
    └── Library chart with a strict values.schema.json + admission policy.
        Approval happens on the library and the policy, not on 200 PRs.

Migrating away from a central chart repo

Most teams reading this already have the monorepo. Do not big-bang it.

  1. Extract the library first. Pull the shared _helpers.tpl and the most common Deployment shape into common 0.1.0 and publish it to the registry. Nothing consumes it yet.
  2. Convert charts in the monorepo to consume the library — still in the monorepo. This is where you discover the forty subtle differences between “the same” Deployment templates. Each one is a decision: a library value, or a documented exception.
  3. Cut common 1.0.0 once the converted charts render identically to what is deployed. helm template diffs against the live release are the acceptance test.
  4. Move charts to their service repos one team at a time, in the order of who asks first. The chart is now three include lines and a values.yaml, so the move is small and the team is getting something (ownership) rather than being handed a chore.
  5. Turn on Renovate for common across the moved repos, and leave the monorepo to shrink until it only contains the library and whatever genuinely shared subcharts remain.

Expect steps 2 and 3 to take most of the time. That is not migration overhead; it is the accumulated drift finally being paid down.

Frequently Asked Questions

What is a Helm library chart?

A library chart is a chart with type: library in its Chart.yaml. It cannot be installed and renders no resources on its own; it exists to be declared as a dependency by application charts, which then include its named templates. Unlike a normal subchart, its templates execute with the parent chart’s .Values, which is what makes it suitable for centralising Deployment, Service and other resource definitions across many services.

Should Helm charts live in the same repo as the application code?

For application charts, yes: the chart is part of the service’s deployable definition and should change in the same PR as the code that needs it. The reusable templates those charts depend on belong in a separate, platform-owned library chart repository. The packaged output of both should be published to an OCI registry, which is the only place deployment tooling should read from.

Is a centralized Helm chart repository ever the right choice?

It is a reasonable first step for a small organisation that just needs consistency quickly, and it remains sensible for the library chart and a handful of genuinely shared subcharts. As the primary home for every service’s chart it does not scale: the platform team becomes the review bottleneck for every values change, and a shared-helper edit has fleet-wide blast radius.

Should I use an OCI registry or a classic Helm repository?

OCI. It has been stable since Helm 3.8, uses the registry and credentials you already run for images, supports immutable digest references, and needs no separate index.yaml server. Classic HTTP repositories still work and are fine for consuming public charts, but new internal charts should be pushed to the OCI registry with helm push.

How do I roll out a platform-wide change to hundreds of Helm charts?

Put the change in the library chart, cut a release, and let Renovate or Dependabot open version-bump PRs on every consuming repository, with auto-merge for patch releases that pass CI. If the change is breaking, release it as a new major so no service picks it up until its team moves the constraint deliberately.

How does a library chart work with Argo CD or Flux?

Transparently. The service chart declares the library as a dependency, so the packaged chart pushed to the registry already contains it under charts/. Argo CD and Flux pull the service chart from the OCI registry and render it as usual; they never need to know the library exists.

Conclusion

The centralized-versus-per-service framing offers a choice between a platform team that cannot keep up and a fleet that cannot be changed. Neither is the destination. The model that scales puts the organisation’s opinions in one versioned library chart, gives each service a thin chart it genuinely owns, and publishes everything through the OCI registry it already runs. It is more work to set up than either pure model, and every large Helm estate ends up there anyway — the only question is whether you arrive by design or by migration.

Helm Version Constraints Explained: The Tilde `~`, Caret `^` and Other SemVer Tricks

Helm Version Constraints Explained: The Tilde `~`, Caret `^` and Other SemVer Tricks

If you have ever written a chart dependency as version: ~1.2.3 and wondered exactly which versions that will and will not pull in — or been surprised when ^0.2.3 refused to upgrade to 0.3.0 — you have run into Helm’s version constraint syntax. It is one of those features everybody uses and almost nobody reads the spec for, which is precisely why it bites.

Helm lets you pin chart versions with a small algebra of range operators: the tilde ~, the caret ^, wildcards (x, *), hyphen ranges, and boolean combinators. Get them right and your helm dependency update pulls exactly the patch releases you want and nothing that breaks you. Get them wrong and you either freeze on a stale sub-chart forever or let a breaking major slip into production.

This guide covers every operator with its exact bounds, the real-world use case for each, the two traps that catch most people (the 0.x caret behavior and silently-skipped pre-releases), and where these constraints are actually evaluated — not just in Chart.yaml dependencies, but on the helm install --version flag too. Everything here reflects current Helm 3 (and is unchanged in Helm 4).

The 30-Second Cheat Sheet

Before the details, here is every operator with its exact expansion. Pin this table.

ConstraintMatches (inclusive/exclusive)Plain English
1.2.3exactly 1.2.3Exact pin
=1.2.3exactly 1.2.3Same as above
!=1.2.3anything except 1.2.3Exclude one version
>=1.2.3, <2.0.0>= 1.2.3 and < 2.0.0Explicit range (AND)
~1.2.3>= 1.2.3, < 1.3.0Patch updates only
~1.2>= 1.2.0, < 1.3.0Patch updates only
~1>= 1.0.0, < 2.0.0Any 1.x
^1.2.3>= 1.2.3, < 2.0.0Minor + patch updates
^0.2.3>= 0.2.3, < 0.3.0⚠️ 0.x pins the minor
^0.0.3>= 0.0.3, < 0.0.4⚠️ 0.0.x pins the patch
1.2.x>= 1.2.0, < 1.3.0Wildcard patch
1.x>= 1.0.0, < 2.0.0Wildcard minor
*>= 0.0.0Anything (avoid)
1.2.3 - 1.4.5>= 1.2.3, <= 1.4.5Inclusive both ends
^1.0.0 || ^2.0.0any 1.x OR any 2.xBoolean OR

The rest of this article is each of these in depth, with the use case and the gotchas.

Where Helm Actually Uses These Constraints

Before the operators, know the two places this syntax is evaluated — they behave identically because they call the same parser:

1. Chart.yaml dependencies

Each entry under dependencies has a version field that “should contain a semantic version or version range”:

apiVersion: v2
name: my-app
version: 1.0.0
dependencies:
  - name: postgresql
    version: "~15.5.0"
    repository: "https://charts.bitnami.com/bitnami"
  - name: redis
    version: "^20.0.0"
    repository: "https://charts.bitnami.com/bitnami"

2. The --version flag

The --version flag on helm install, upgrade, pull, template and friends. Straight from the CLI docs: “This constraint can be a specific tag (e.g. 1.1.1) or it may reference a valid range (e.g. ^2.0.0). If this is not specified, the latest version is used.”

# Latest patch of 15.5
helm install db bitnami/postgresql --version "~15.5.0"

# Any 20.x, resolved at install time
helm upgrade cache bitnami/redis --version "^20.0.0"

Under the hood, Helm 3 parses all of this with the Masterminds/semver v3 Go library (the same one Helm 4 ships). Every rule below comes from that library’s behavior, so it is consistent everywhere Helm accepts a version.

The Tilde ~: Patch Updates Only

The tilde is the conservative choice: allow patch releases, freeze the minor. It is the operator you want for a dependency you trust to follow SemVer but don’t want surprising you with new features (and new bugs) on a minor bump.

The rule: with a minor version specified, ~ allows patch-level changes; with only the major specified, it allows minor-level changes.

ConstraintExpands to
~1.2.3>= 1.2.3, < 1.3.0
~1.2>= 1.2.0, < 1.3.0
~1>= 1.0.0, < 2.0.0
~1.2.x>= 1.2.0, < 1.3.0

Use case: a stateful dependency like a database chart where minor bumps can change StatefulSet fields, default storage, or init logic. version: "~15.5.0" says “give me 15.5.4, 15.5.9 — the bug fixes — but never jump me to 15.6 automatically.” This is the pattern Helm’s own dependencies best-practices page recommends as the default: version: ~1.2.3.

The Caret ^: Minor + Patch (and the 0.x Trap)

The caret is the “trust SemVer” operator: allow anything that shouldn’t break you — new minors and patches, but not a new major. For a well-behaved dependency past 1.0.0, this is the sweet spot between staying current and staying safe.

ConstraintExpands to
^1.2.3>= 1.2.3, < 2.0.0
^1.2>= 1.2.0, < 2.0.0
^1>= 1.0.0, < 2.0.0

Use case: a mature library chart (an ingress controller, a metrics exporter) that reliably reserves breaking changes for major bumps. version: "^20.0.0" keeps you on the latest 20.x without manual bumps, and stops cold at 21.0.0 so a breaking change never lands silently.

⚠️ The 0.x Caret Trap

Here is the single most misremembered rule in the whole system. Before a 1.0.0 release, the caret treats the minor number as the stability level, because in 0.x land every minor bump is allowed to break things. So:

ConstraintExpands toNot what you might expect
^0.2.3>= 0.2.3, < 0.3.0not < 1.0.0
^0.2>= 0.2.0, < 0.3.0
^0.0.3>= 0.0.3, < 0.0.4pins the patch
^0.0>= 0.0.0, < 0.1.0
^0>= 0.0.0, < 1.0.0

If you write ^0.2.3 expecting it to track every 0.x release up to 1.0.0, you will be quietly stuck on the 0.2 line — 0.3.0 will never be pulled. On a 0.x dependency, caret behaves essentially like tilde. Given how many CNCF-adjacent charts sit at 0.x for years, this is a real-world footgun, not a trivia question. If you genuinely want to float across 0.x minors, use an explicit range: >= 0.2.3, < 1.0.0.

Wildcards: x, X and *

Wildcards let you leave a position open. They work with the comparison operators too, and on a bare = they fall back to tilde-style patch matching.

ConstraintExpands to
1.2.x>= 1.2.0, < 1.3.0
1.x>= 1.0.0, < 2.0.0
>= 1.2.x>= 1.2.0
<= 2.x< 3.0.0
*>= 0.0.0 (anything)

Use case: 1.2.x reads more explicitly than ~1.2.0 to some teams and means the same thing — pick whichever your reviewers parse faster. A bare * matches everything and should be treated as a code smell in a committed Chart.yaml; it defeats the entire point of a lock and invites a breaking major on the next helm dependency update.

Hyphen Ranges: Inclusive Both Ends

A hyphen range gives you an explicit, inclusive window on both ends — handy when you know a dependency is good from version A through version B and you want to say exactly that.

ConstraintExpands to
1.2.3 - 1.4.5>= 1.2.3, <= 1.4.5
2.3.4 - 4.5>= 2.3.4, <= 4.5

⚠️ The whitespace matters. The spaces around the hyphen are mandatory. Written without them, 1.2.3-1.4.5 is not a range at all — it parses as the single version 1.2.3 with the pre-release identifier 1.4.5. That is a completely different (and almost certainly unintended) constraint, and it will fail to match silently. Always keep the spaces: 1.2.3 - 1.4.5.

Boolean Logic: AND, OR and Comparisons

You can combine constraints. Within a group, space and comma both mean AND — they are interchangeable. Groups are then joined with || for OR.

# AND — a bounded window
version: ">= 1.2.0, < 1.5.0"

# equivalent (space instead of comma)
version: ">= 1.2.0 < 1.5.0"

# OR — support two major lines at once
version: "^1.0.0 || ^2.0.0"

# real-world: pin a window but exclude one bad release
version: ">= 1.2.0, < 2.0.0, != 1.4.2"

The full set of comparison operators is what you’d expect: =, !=, >, <, >=, <=. The != is underused and genuinely handy — when a specific patch ships a regression, != 1.4.2 skips exactly that one without abandoning your range.

Use case for ||: a chart that supports two major versions of a dependency during a migration window. ^1.0.0 || ^2.0.0 accepts either line, letting downstream users move at their own pace.

The Pre-Release Gotcha Nobody Reads

This one silently breaks CI pipelines. Ranges skip pre-release versions by default. A constraint like ~1.2.3 or >= 1.2.0 will not match 1.3.0-rc.1 or 1.2.5-beta.2, even though those versions are numerically inside the range.

The reason is deliberate: you rarely want a helm dependency update to pull a release candidate into a production chart. But it surprises people who tag pre-releases and wonder why Helm ignores them.

To opt in, add a pre-release comparator to the constraint itself — the idiomatic trick is appending -0:

# Ignores 1.2.4-rc.1 (default behavior)
version: "~1.2.3"

# Matches pre-releases too, e.g. 1.2.4-rc.1
version: "~1.2.3-0"

The -0 works because pre-release identifiers sort in ASCII order and 0 is the lowest possible, so it acts as “any pre-release or higher.” One more sharp edge from the same rule: comparisons are ASCII-cased, so >= 1.2.3-BETA will match 1.2.3-alpha, because uppercase B sorts before lowercase a. If you use pre-release channels, keep the casing consistent.

Chart.yaml Range vs Chart.lock Pin: What Actually Reproduces a Build

A constraint is a range, not a pin — and understanding the difference between Chart.yaml and Chart.lock is what separates reproducible builds from “works on my machine.”

  • Chart.yaml holds the range (~15.5.0). It expresses intent: “acceptable versions.”
  • Chart.lock holds the resolved, concrete versions plus a digest. It is generated, and it is what actually reproduces a build.

The two commands that interact with them do opposite things:

# Re-resolves the RANGE in Chart.yaml against the repo,
# picks concrete versions, and rewrites Chart.lock.
helm dependency update

# Ignores the range; rebuilds charts/ from the PIN in Chart.lock.
helm dependency build

The practical rule: commit your Chart.lock. In CI, use helm dependency build so every pipeline run installs the exact versions the lock pins — the range in Chart.yaml only governs re-resolution when you deliberately run update. Treating the range as if it were the pin is how teams end up with subtly different sub-chart versions across environments. If you’re formalizing chart quality more broadly, this pairs well with a proper chart testing setup.

Since Which Helm Version?

Two things people conflate here — the syntax and the location:

  • The constraint syntax itself is not new. Helm 2 already parsed the same Masterminds/semver ranges; it just kept dependencies in a separate requirements.yaml file.
  • What changed in Helm 3 is where dependencies live. With apiVersion: v2 charts (the Helm 3 format, released November 2019), dependencies moved into Chart.yaml. Helm’s docs are explicit: the change from v1 to v2 “added a dependencies field defining chart dependencies, which were located in a separate requirements.yaml file for v1 charts.”

So if you are on any modern Helm (3.x or the new Helm 4), the full operator set in this article is available in your Chart.yaml and on --version. If you still maintain an ancient apiVersion: v1 chart, the same operators work — they just live in requirements.yaml. Set apiVersion: v2 and move the block into Chart.yaml when you migrate.

Which Operator Should You Use? A Decision Guide

Your situationUse
Production DB / stateful chart, want only bug fixes~1.2.3 (patch only)
Well-behaved library past 1.0.0, want to stay current^1.2.3 (minor + patch)
A 0.x dependency you want to float across minorsexplicit >= 0.2.3, < 1.0.0 — not ^0.2.3
Exact reproducibility, no surprisesexact pin 1.2.3 + committed Chart.lock
Skip one known-bad release inside a range>= 1.2.0, < 2.0.0, != 1.4.2
Support two majors during a migration^1.0.0 || ^2.0.0
You need release candidatesappend -0, e.g. ~1.2.3-0

The honest default for most teams: use ~ (tilde) for stateful and infrastructure-critical dependencies where you want bug fixes and nothing else, use ^ (caret) for mature libraries you trust to respect SemVer, and always commit Chart.lock so the range never decides what actually deploys — the lock does.

Frequently Asked Questions

What does the tilde (~) mean in a Helm chart version?

The tilde allows patch-level updates and freezes the minor version: ~1.2.3 expands to >= 1.2.3, < 1.3.0, and ~1.2 means the same thing. With only a major specified, it widens one level (~1 is any 1.x). It is the operator Helm’s own dependency best-practices page recommends as the default, because it lets bug fixes through and blocks new minors that may change templates or defaults.

What does the caret (^) mean in Helm, and why does ^0.2.3 not match 0.3.0?

The caret allows minor and patch updates but never a new major: ^1.2.3 expands to >= 1.2.3, < 2.0.0. Below 1.0.0 the rule changes because in 0.x land any minor bump may break things, so the caret pins the left-most non-zero position: ^0.2.3 becomes >= 0.2.3, < 0.3.0 and ^0.0.3 becomes >= 0.0.3, < 0.0.4. If you want to float across 0.x minors, write the range explicitly: >= 0.2.3, < 1.0.0.

Can I use a version range with helm install –version?

Yes. The --version flag on helm install, helm upgrade, helm pull and helm template accepts the same constraint syntax as Chart.yaml dependencies, so --version "~15.5.0" installs the newest 15.5.x available in the repository at that moment. If the flag is omitted, Helm uses the latest non-pre-release version. Both places go through the same Masterminds/semver parser, so the expansion rules are identical.

Why does my Helm dependency ignore pre-release versions?

Because ranges skip pre-releases by default: ~1.2.3 will not match 1.2.4-rc.1 even though it is numerically inside the window. This is deliberate, so that helm dependency update never pulls a release candidate into a production chart by accident. To opt in, append a pre-release comparator to the constraint, typically -0, as in ~1.2.3-0, which matches any pre-release of the allowed versions as well as the final releases.

What is the difference between Chart.yaml and Chart.lock?

Chart.yaml stores the version range, which expresses which versions are acceptable; Chart.lock stores the concrete versions and digest that were actually resolved, and it is what reproduces a build. helm dependency update re-resolves the ranges and rewrites the lock, while helm dependency build ignores the ranges and rebuilds charts/ from the lock. Commit Chart.lock and use dependency build in CI so every pipeline installs exactly the same sub-chart versions.

Wrapping Up

Helm’s version constraints are a small language, but the two traps — caret pinning the minor on 0.x, and ranges silently skipping pre-releases — cause an outsized share of “why won’t it upgrade?” and “why did that break?” incidents. Internalize the cheat-sheet table, prefer ~ and ^ over bare wildcards, and let Chart.lock be the source of truth for reproducible builds.

For more on getting Helm right in production, see the companion guides on Helm values JSON schema validation, loading external files into ConfigMaps and Secrets, and what’s new in Helm 4.

Sources

Using ~ (null) in Helm: Deleting Default Values and Handling Optional Fields

Using ~ (null) in Helm: Deleting Default Values and Handling Optional Fields

You override a chart’s default livenessProbe with an exec command, deploy, and Kubernetes rejects it: “may not specify more than one handler type.” The chart’s default httpGet probe is still there, merged underneath your override, and now the pod spec has two probe handlers. You didn’t add it. You can’t see it in your values file. And the fix is a single character: ~.

That ~ is YAML’s null, and in Helm it does something most people never learn: setting a key to null deletes it from the merged values entirely, instead of setting it to a null value. It’s the cleanest way to remove a default a chart baked in — and it’s the tip of a whole set of null-handling behaviors (absent vs null vs empty, --set foo=null, default, required, hasKey) that quietly decide whether your templates render valid YAML or foo: <no value>.

This guide covers the null-deletes-a-key trick in depth, where it works and where it bites (the --reuse-values trap, the Helm 4 regression), and the related functions you need to tell “the user didn’t set this” apart from “the user set this to empty.” Everything is verified against current Helm docs and behavior.

First: ~ is just YAML null

Before Helm, this is pure YAML. All of these mean the same thing — null:

a: ~        # canonical shorthand
b: null
c: Null
d: NULL
e:          # empty value is also null

~ is the canonical short form in the YAML spec; null/Null/NULL/empty are equivalent spellings. In a Helm values.yaml, writing foo: ~ is identical to foo: null. People reach for ~ because it’s terse and unmistakable — an empty value (foo:) is easy to misread as “I forgot to fill this in,” whereas foo: ~ reads as a deliberate null.

The Killer Trick: null Deletes a Default Key

Here is the behavior that makes ~ worth an article. When you override a chart’s values — with a -f values file, a parent chart overriding a subchart, or --set — Helm merges your values on top of the chart’s defaults. A normal override replaces a value. But a null override is special: Helm removes the key from the result.

Straight from the Helm docs (Chart Template Guide → Values Files):

“If you need to delete a key from the default values, you may override the value of the key to be null, in which case Helm will remove the key from the overridden values merge.”

The canonical example is the liveness-probe foot-gun from the intro. Say the chart defaults to:

# chart's values.yaml
livenessProbe:
  httpGet:
    path: /healthz
    port: 8080

You want an exec probe instead. If you just add your exec, the merge keeps the default httpGet — and a probe with both exec and httpGet is invalid. You delete the default with null:

# your override values.yaml
livenessProbe:
  httpGet: ~          # ← deletes the chart's default httpGet
  exec:
    command: [cat, docroot/CHANGELOG.txt]

Or on the command line, exactly as the Helm docs show it:

helm install stable/drupal \
  --set livenessProbe.exec.command='{cat,docroot/CHANGELOG.txt}' \
  --set livenessProbe.httpGet=null

The result has only the exec handler. Without the httpGet=null, both survive and Kubernetes rejects the manifest.

Why it works: Helm’s value coalescing walks the override tree onto the defaults. A present key with a real value overwrites; a present key with null is treated as an instruction to remove. This is the only way to subtract from a chart’s defaults — there’s no --unset flag (more on that below).

Where the null-delete works

The same mechanism applies anywhere Helm coalesces values:

  • A -f override file on top of the chart’s values.yaml (the docs example above).
  • A parent chart overriding a subchart. In the parent’s values.yaml, nulling a key under the subchart’s name deletes that subchart default:
  • “`yaml
  • # parent values.yaml
  • mysubchart:
  • someDefault: ~ # remove a default the subchart shipped
  • “`
  • --set key=null at install/upgrade time.

--set foo=null vs --set-string foo=null

On the CLI the distinction matters, and it’s a common source of “why didn’t it delete?”:

CommandResult
--set foo=nullfoo becomes a real nil → key is deleted
--set-string foo=nullfoo becomes the literal string "null" (no deletion)
--set a=null,name=[]a: null, name: []

Helm’s --set parser does type conversion: the literal null (case-insensitive) is converted to a Go nil, which triggers the delete. --set-string forces everything to stay a string, so null is just the four-character word "null" — which is almost never what you want here. If your deletion isn’t happening, check you didn’t reach for --set-string.

Version Support (and the Helm 4 warning)

This is where “desde qué versión” gets interesting:

  • Helm 2 introduced deleting a key by setting it to null, but only reliably for top-level keys. Nested null-deletion (like web.livenessProbe.httpGet: null) was buggy — it could emit Cannot overwrite table item ... with non table value and override with null instead of deleting.
  • Helm 3 is where the documented behavior works as advertised, top-level and nested. If you’re on Helm 3, everything above is solid.
  • ⚠️ Helm 4 — currently a regression. As of a still-open issue (filed March 2026), Helm 4 no longer reliably deletes keys via null: both foo: (blank) and foo: null can fail schema validation with errors like Invalid value: "null": ... must be of type string, especially against strict Kubernetes 1.34+ schemas. If you’ve moved to Helm 4, test null-deletion before relying on it — the behavior that was rock-solid in Helm 3 is in flux. This interacts directly with values JSON schema validation: a schema that types a field as string will reject a null, so schema and null-deletion can fight each other.

The --reuse-values Trap

The one place null-deletion does not do what you’d hope: helm upgrade --reuse-values.

--set foo=null deletes a key relative to the chart’s defaults. It does not cleanly “un-set” a value you previously set explicitly and are now carrying forward with --reuse-values — it overrides it with null rather than falling back to the chart default. There is a long-standing feature request for an explicit --unset flag precisely because this case has no clean answer today.

Practical rule: to genuinely reset a value back to the chart default on upgrade, prefer re-specifying your full intended values (-f) over leaning on --reuse-values plus --set x=null.

Absent vs null vs empty: The Trio That Trips Everyone

Deleting keys is half the story. The other half is reading optional values in templates — and Helm/Sprig blur three states that feel different: key absent, key present but null, and key present but empty (0, "", [], {}, false).

The critical thing to internalize: default, required, empty, and coalesce all treat nil, 0, "", empty list/map, and false as the same “empty.” They cannot tell “unset” from “set to zero.”

replicas: 0        # a DELIBERATE zero...
```
```gotemplate
{{ .Values.replicas | default 3 }}   # ...renders 3, not 0 — surprise!

Here’s what each tool actually does:

You want to…UseBehavior
Provide a fallback for empty/unset`{{ .Values.foo \default “x” }}`Returns "x" if foo is nil, 0, "", [], {}, or false
Fail loudly if unset{{ required "foo is required" .Values.foo }}Errors on nil and on empty string (same “empty” rule as default)
First non-empty of several{{ coalesce .Values.a .Values.b "x" }}Skips every empty/null, returns first real value
Tell present-but-null from absent{{ if hasKey .Values "foo" }}The only reliable presence check — true even when the value is null
Safely read a nested optional{{ dig "a" "b" "fallback" .Values }}Walks .a.b, returns "fallback" if any level is missing
Branch on a condition{{ ternary "yes" "no" .Values.enabled }}"yes" if truthy, "no" if empty/false

If you need to honor a deliberate 0 or false, default is wrong — use hasKey to check presence explicitly:

replicas: {{ if hasKey .Values "replicas" }}{{ .Values.replicas }}{{ else }}3{{ end }}

The Rendering Foot-gun: <no value> vs null vs ""

The nastiest null bug isn’t logic — it’s a null leaking into your YAML as a broken string. The same nil value renders three different ways:

foo: {{ .Values.foo }}            # → foo: <no value>   ❌ invalid YAML-ish garbage
foo: {{ .Values.foo | toYaml }}   # → foo: null         ✅ valid YAML null
foo: {{ .Values.foo | quote }}    # → foo: ""           ✅ valid empty string

Bare-printing an unset value gives you the literal text <no value> in the manifest — which is not null, not empty, just a string that will confuse Kubernetes or your reader. (You may also see <nil> in some contexts; the exact literal is a Go-template detail that has shifted across Helm 3 minor versions, so don’t hard-code assumptions about which one appears — the point is it’s not what you want.)

The fixes:

  • Piping through toYaml turns nil into a proper null — use it for whole objects/maps: {{ .Values.config | toYaml | nindent 2 }}.
  • Piping through quote turns nil into "" — use it for optional scalars that must be strings.
  • Best of all, skip the key entirely when empty with with:
  • “`gotemplate
  • {{- with .Values.foo }}
  • foo: {{ . | quote }}
  • {{- end }}
  • “`
  • The with block is skipped for any empty/nil value, so an unset foo produces no line at all — the idiomatic Helm “omitempty.”

Switching Off a Subchart Block with null

One more practical use of ~. Because nil is “empty,” setting a value to null makes an {{ if }} guard fall through:

# subchart default turns something on
metrics:
  serviceMonitor:
    enabled: true
```
```yaml
# parent override switches it off cleanly
metrics:
  serviceMonitor: ~     # or: enabled: false

Nulling the whole block (or the flag) makes {{ if .Values.metrics.serviceMonitor }} evaluate false — a tidy way for a parent chart or an environment override to disable a section a subchart enabled by default, using the same empty-semantics as everything above.

Cheat Sheet

GoalSyntax
Write a null in valuesfoo: ~ (or foo: null)
Delete a chart’s default keyoverride it with ~ / null
Delete via CLI--set foo=null (not --set-string)
Fallback for empty/unset`{{ .Values.foo \default “x” }}`
Distinguish null from absent{{ if hasKey .Values "foo" }}
Require a value{{ required "msg" .Values.foo }}
Nested optional read{{ dig "a" "b" "fallback" .Values }}
Null-safe object into YAML`{{ .Values.obj \toYaml \nindent 2 }}`
Omit a key when empty{{- with .Values.foo }} … {{- end }}

Wrapping Up

~ in Helm is a two-job character: it writes a YAML null, and — the part almost nobody documents in their own charts — it deletes a default key from the merged values, which is the only clean way to subtract a probe, an annotation, or a whole block that a chart shipped by default. Around it sits a set of null rules worth memorizing: default/required/empty can’t tell unset from zero (use hasKey when that matters), and an unset value bare-printed becomes <no value> unless you route it through toYaml, quote, or with.

Two warnings to carry: --reuse-values doesn’t cleanly un-set values, and Helm 4’s null-deletion is currently a regression — so if you’re on 4.x, verify before you rely on it.

For more Helm depth, see the companion guides on values JSON schema validation (which interacts directly with null handling), loading external files into ConfigMaps and Secrets, and what’s new in Helm 4.

Sources

ArgoCD Guide: GitOps Continuous Delivery for Kubernetes

ArgoCD Guide: GitOps Continuous Delivery for Kubernetes

ArgoCD has become the de facto standard for GitOps-based continuous delivery in Kubernetes. If you are running production workloads on Kubernetes and still deploying with raw kubectl apply or untracked Helm releases, ArgoCD solves a class of problems you may not even know you have yet. This guide covers everything from core concepts to production-grade configuration.

The Problem ArgoCD Solves

Traditional CI/CD pushes deployments into a cluster. A CI system runs tests, builds an image, and then executes kubectl apply or helm upgrade against the cluster. This model has several structural problems:

  • Drift goes undetected. Someone applies a hotfix directly to the cluster. Now your Git repository no longer reflects reality, and nobody knows it.
  • No single source of truth. The cluster state is authoritative, not Git. Your desired state and actual state can diverge silently.
  • Rollback is painful. Rolling back a bad deployment means re-running old CI pipelines or manually reversing changes, neither of which is fast.
  • Multi-cluster management compounds the problem. Each cluster becomes a snowflake with its own history of undocumented changes.

GitOps inverts this model. Git is the source of truth. The cluster pulls its desired state from Git and continuously reconciles toward it. ArgoCD is the most mature GitOps operator for Kubernetes, implementing this pull-based model with a production-ready feature set.

How ArgoCD Works: Core Architecture

ArgoCD runs as a set of controllers inside your Kubernetes cluster. The core components are:

  • Application Controller — Watches both the Git repository and the live cluster state. Computes the diff and drives reconciliation.
  • API Server — Exposes the gRPC/REST API consumed by the CLI, UI, and external systems.
  • Repository Server — Generates Kubernetes manifests from source (Helm, Kustomize, plain YAML, Jsonnet).
  • Redis — Caches cluster state and repository data to reduce API server load.
  • Dex (optional) — Provides OIDC authentication for SSO integration.

The fundamental unit in ArgoCD is an Application — a CRD that maps a source (a path in a Git repo at a specific revision) to a destination (a namespace in a cluster). ArgoCD continuously compares the desired state from Git with the live state in the cluster and reports on the sync status.

Sync Status vs Health Status

Two orthogonal concepts you need to understand from day one:

  • Sync Status — Does the live state match what Git says it should be? Values: Synced, OutOfSync, Unknown.
  • Health Status — Is the application actually working? Values: Healthy, Progressing, Degraded, Suspended, Missing, Unknown.

An application can be Synced but Degraded — the manifests were applied correctly, but a pod is crash-looping. Conversely, it can be OutOfSync but Healthy — someone applied a change directly to the cluster outside of Git.

Installing ArgoCD

The official installation method uses a single manifest. For production, always pin to a specific version:

kubectl create namespace argocd
kubectl apply -n argocd -f https://raw.githubusercontent.com/argoproj/argo-cd/v2.11.0/manifests/install.yaml

This deploys ArgoCD in the argocd namespace with full cluster-admin access. For a production HA setup, use the manifests/ha/install.yaml variant, which deploys multiple replicas of the API server and application controller.

Accessing the UI and CLI

The initial admin password is auto-generated and stored in a secret:

argocd admin initial-password -n argocd

For local access, port-forward the API server:

kubectl port-forward svc/argocd-server -n argocd 8080:443

Then log in via the CLI:

argocd login localhost:8080 --username admin --password <password> --insecure

For production, expose the ArgoCD server via an Ingress or LoadBalancer with a proper TLS certificate. If you’re using NGINX Ingress Controller:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: argocd-server-ingress
  namespace: argocd
  annotations:
    nginx.ingress.kubernetes.io/ssl-passthrough: "true"
    nginx.ingress.kubernetes.io/backend-protocol: "HTTPS"
spec:
  ingressClassName: nginx
  rules:
  - host: argocd.yourdomain.com
    http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: argocd-server
            port:
              number: 443

Defining Your First Application

Applications can be created via the UI, the CLI, or declaratively with a YAML manifest. The declarative approach is the recommended one — it means your ArgoCD configuration itself is in Git:

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: my-app
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://github.com/your-org/your-app
    targetRevision: HEAD
    path: k8s/overlays/production
  destination:
    server: https://kubernetes.default.svc
    namespace: production
  syncPolicy:
    automated:
      prune: true
      selfHeal: true
    syncOptions:
    - CreateNamespace=true

Key fields to understand:

  • targetRevision — Can be a branch name, tag, or commit SHA. For production, pin to a tag rather than HEAD.
  • path — The directory within the repo containing your Kubernetes manifests.
  • automated.prune — Automatically delete resources that are no longer in Git. Required for true GitOps but use carefully — it will delete things.
  • automated.selfHeal — Automatically revert manual changes made directly to the cluster. This is what enforces Git as the single source of truth.

Helm Integration

ArgoCD has native Helm support. It can deploy Helm charts directly from chart repositories or from your Git repository. You can override values per environment:

apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
  name: prometheus-stack
  namespace: argocd
spec:
  project: default
  source:
    repoURL: https://prometheus-community.github.io/helm-charts
    chart: kube-prometheus-stack
    targetRevision: 58.4.0
    helm:
      releaseName: prometheus-stack
      valuesObject:
        grafana:
          adminPassword: "${GRAFANA_PASSWORD}"
        prometheus:
          prometheusSpec:
            retention: 30d
            storageSpec:
              volumeClaimTemplate:
                spec:
                  storageClassName: fast-ssd
                  resources:
                    requests:
                      storage: 50Gi
  destination:
    server: https://kubernetes.default.svc
    namespace: observability

One important nuance: ArgoCD renders Helm charts server-side using its own templating engine, not helm install. This means Helm hooks (pre-install, post-upgrade, etc.) are supported, but the release is not tracked in Helm’s release history. Running helm list will not show ArgoCD-managed releases unless you configure ArgoCD to use the Helm secrets backend.

Projects: Multi-Tenancy and Access Control

ArgoCD Projects provide multi-tenancy within a single ArgoCD instance. They let you restrict which source repositories, destination clusters, and namespaces a team can deploy to. Every Application belongs to a Project.

apiVersion: argoproj.io/v1alpha1
kind: AppProject
metadata:
  name: platform-team
  namespace: argocd
spec:
  description: Platform team applications
  sourceRepos:
  - 'https://github.com/your-org/*'
  destinations:
  - namespace: 'platform-*'
    server: https://kubernetes.default.svc
  clusterResourceWhitelist:
  - group: ''
    kind: Namespace
  namespaceResourceBlacklist:
  - group: ''
    kind: ResourceQuota

Projects are where you define the boundaries of what each team can do. The default project has no restrictions — never use it for production workloads. Create dedicated projects per team or per environment.

RBAC Configuration

ArgoCD has its own RBAC system layered on top of Kubernetes RBAC. It is configured via the argocd-rbac-cm ConfigMap. Roles are defined per project or globally:

apiVersion: v1
kind: ConfigMap
metadata:
  name: argocd-rbac-cm
  namespace: argocd
data:
  policy.default: role:readonly
  policy.csv: |
    # Platform team has full access to platform-team project
    p, role:platform-admin, applications, *, platform-team/*, allow
    p, role:platform-admin, projects, get, platform-team, allow
    p, role:platform-admin, repositories, *, *, allow

    # Dev team can sync but not delete
    p, role:developer, applications, get, */*, allow
    p, role:developer, applications, sync, */*, allow
    p, role:developer, applications, action/*, */*, allow

    # Bind SSO groups to roles
    g, your-org:platform-team, role:platform-admin
    g, your-org:developers, role:developer

The policy.default: role:readonly ensures that any authenticated user who has no explicit role assignment gets read-only access — a safe default for production.

Multi-Cluster Management

ArgoCD can manage multiple Kubernetes clusters from a single control plane. Register external clusters with the CLI:

# First, ensure the target cluster context is in your kubeconfig
argocd cluster add production-eu-west --name production-eu-west

# Verify registration
argocd cluster list

ArgoCD will create a ServiceAccount in the target cluster and store its credentials as a Kubernetes secret in the ArgoCD namespace. Applications can then target this cluster by name in their destination.server field.

For large-scale multi-cluster setups, consider the App of Apps pattern or ApplicationSets. ApplicationSets are a controller that generates Applications dynamically based on generators — cluster lists, Git directory structures, or matrix combinations:

apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
  name: cluster-addons
  namespace: argocd
spec:
  generators:
  - clusters:
      selector:
        matchLabels:
          environment: production
  template:
    metadata:
      name: '{{name}}-addons'
    spec:
      project: platform
      source:
        repoURL: https://github.com/your-org/cluster-addons
        targetRevision: HEAD
        path: 'addons/{{metadata.labels.region}}'
      destination:
        server: '{{server}}'
        namespace: kube-system

This single ApplicationSet deploys the appropriate addons to every cluster labeled environment: production, using each cluster’s region label to select the correct path in the repository.

Sync Strategies and Waves

When deploying complex applications with dependencies between resources, you need to control the order of deployment. ArgoCD provides two mechanisms:

Sync Phases

Resources are deployed in three phases: PreSync, Sync, and PostSync. Use Sync Hooks for resources that must complete before the main sync proceeds (database migrations, certificate issuance, etc.):

apiVersion: batch/v1
kind: Job
metadata:
  name: db-migration
  annotations:
    argocd.argoproj.io/hook: PreSync
    argocd.argoproj.io/hook-delete-policy: HookSucceeded
spec:
  template:
    spec:
      containers:
      - name: migrate
        image: your-app:v1.2.3
        command: ["./migrate.sh"]
      restartPolicy: Never

Sync Waves

Within the Sync phase, waves control ordering. Resources with a lower wave number are applied and must become healthy before resources with higher wave numbers are applied:

# Applied first
metadata:
  annotations:
    argocd.argoproj.io/sync-wave: "1"

# Applied after wave 1 is healthy
metadata:
  annotations:
    argocd.argoproj.io/sync-wave: "2"

Notifications and Alerting

ArgoCD Notifications is a standalone controller that sends alerts when Application state changes. It supports Slack, PagerDuty, GitHub commit status, email, and a dozen other providers. Configure it via the argocd-notifications-cm ConfigMap:

apiVersion: v1
kind: ConfigMap
metadata:
  name: argocd-notifications-cm
  namespace: argocd
data:
  service.slack: |
    token: $slack-token
  template.app-sync-failed: |
    slack:
      attachments: |
        [{
          "title": "{{.app.metadata.name}}",
          "color": "#E96D76",
          "fields": [{
            "title": "Sync Status",
            "value": "{{.app.status.sync.status}}",
            "short": true
          },{
            "title": "Message",
            "value": "{{range .app.status.conditions}}{{.message}}{{end}}",
            "short": false
          }]
        }]
  trigger.on-sync-failed: |
    - when: app.status.sync.status == 'Unknown'
      send: [app-sync-failed]
    - when: app.status.operationState.phase in ['Error', 'Failed']
      send: [app-sync-failed]

Secret Management with ArgoCD

ArgoCD intentionally has no secret management built in — storing secrets in Git as plain text is never acceptable. The common patterns are:

  • Sealed Secrets (Bitnami) — Encrypts secrets with a cluster-specific key. The encrypted secret can be committed to Git; only the cluster can decrypt it.
  • External Secrets Operator — Syncs secrets from Vault, AWS Secrets Manager, GCP Secret Manager, etc. into Kubernetes secrets. The ArgoCD Application manages the ExternalSecret CRD, not the actual secret value.
  • argocd-vault-plugin — A plugin that replaces placeholder values in manifests with secrets retrieved from Vault at sync time.

The External Secrets Operator approach is the most flexible for teams already using a centralized secrets backend. The Application in ArgoCD deploys ExternalSecret objects, which the ESO controller resolves at runtime without ever touching Git.

Production Best Practices

  • Run ArgoCD in HA mode. Use manifests/ha/install.yaml with 3 replicas of the API server and multiple application controller shards for large clusters (100+ applications).
  • Pin image versions. Never use latest for the ArgoCD image itself. Pin to a specific version and upgrade deliberately.
  • Use the App of Apps pattern for bootstrapping. A single root Application deploys all other Applications. This makes cluster bootstrapping idempotent and reproducible.
  • Separate ArgoCD config from application config. Store ArgoCD Application manifests in a dedicated gitops repository, separate from application source code.
  • Enable resource tracking via annotations. Use application.resourceTrackingMethod: annotation in argocd-cm instead of the default label-based tracking, which can conflict with Helm’s own labels.
  • Set resource limits on ArgoCD controllers. Application controller CPU and memory scale with the number of resources tracked. Monitor and tune accordingly.
  • Restrict auto-sync in production. Consider requiring manual sync approval for production environments even when using GitOps — or at minimum require a PR approval gate before changes reach the target branch.

ArgoCD vs Flux

Flux v2 is the other major GitOps operator. Both are CNCF projects. The main differences in practice:

FeatureArgoCDFlux v2
UIBuilt-in web UINo official UI (use Weave GitOps)
Multi-clusterSingle control plane manages many clustersAgent per cluster, pull model
ApplicationSetsNativeKustomization + HelmRelease
Secret managementPlugin-basedSOPS native integration
Learning curveSteeper (more concepts)Lower (Kubernetes-native CRDs)
CNCF statusGraduatedGraduated

ArgoCD wins when you need the UI, multi-cluster management from a central plane, or have a large operations team that benefits from the visual application topology view. Flux wins when you want a simpler, purely Kubernetes-native approach with better SOPS integration for secret management.

FAQ

Can ArgoCD deploy to the cluster it runs in?

Yes. The https://kubernetes.default.svc destination refers to the local cluster. ArgoCD can manage both its own cluster and external clusters simultaneously.

Does ArgoCD support private Git repositories?

Yes. Configure repository credentials via argocd repo add with SSH keys, HTTPS username/password, or GitHub App credentials. Credentials are stored as Kubernetes secrets in the ArgoCD namespace.

How does ArgoCD handle CRD installation?

CRDs can be managed by ArgoCD, but there is a chicken-and-egg problem: if a CRD is not yet installed, ArgoCD cannot validate resources that use it. The recommended pattern is to put CRDs in wave 1 and dependent resources in wave 2, or to use a separate Application for CRDs.

What is the difference between an Application and an AppProject?

An Application is the unit of deployment — it maps a Git source to a cluster destination. An AppProject is a grouping and access control boundary — it restricts what sources and destinations an Application within the project can use. Every Application belongs to exactly one AppProject.

How do I roll back a deployment with ArgoCD?

The GitOps way: revert the commit in Git and let ArgoCD reconcile. ArgoCD also provides a UI-based rollback to any previous sync revision, but this is considered a temporary measure — the Git history should always be updated to match.

Getting Started

The fastest path from zero to a working ArgoCD setup on a local cluster:

# 1. Create a local cluster (kind or minikube)
kind create cluster --name argocd-demo

# 2. Install ArgoCD
kubectl create namespace argocd
kubectl apply -n argocd -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml

# 3. Wait for pods
kubectl wait --for=condition=Ready pods --all -n argocd --timeout=120s

# 4. Get the initial admin password
argocd admin initial-password -n argocd

# 5. Port-forward and log in
kubectl port-forward svc/argocd-server -n argocd 8080:443 &
argocd login localhost:8080 --username admin --insecure

# 6. Deploy your first application
argocd app create guestbook 
  --repo https://github.com/argoproj/argocd-example-apps.git 
  --path guestbook 
  --dest-server https://kubernetes.default.svc 
  --dest-namespace guestbook 
  --sync-policy automated

From here, the natural next steps are integrating ArgoCD with your existing CI pipeline (CI builds and pushes the image, updates the image tag in Git, ArgoCD detects the change and syncs), configuring SSO via Dex, and setting up the App of Apps pattern for managing multiple applications declaratively.

For teams looking to go deeper on GitOps and ArgoCD in production, the Kubernetes architecture patterns guide covers how ArgoCD fits into a broader platform engineering stack alongside service mesh, policy enforcement, and observability tooling.

Helm Chart Testing in Production: Layers, Tools, and a Minimum CI Pipeline

Helm Chart Testing in Production: Layers, Tools, and a Minimum CI Pipeline

When a Helm chart fails in production, the impact is immediate and visible. A misconfigured ServiceAccount, a typo in a ConfigMap key, or an untested conditional in templates can trigger incidents that cascade through your entire deployment pipeline. The irony is that most teams invest heavily in testing application code while treating Helm charts as “just configuration.”

Related reading: Helm chart management: centralized vs per-service.

Chart testing is fundamental for production-quality Helm deployments. For comprehensive coverage of testing along with all other Helm best practices, visit our complete Helm guide.

Helm charts are infrastructure code. They define how your applications run, scale, and integrate with the cluster. Treating them with less rigor than your application logic is a risk most production environments cannot afford.

The Real Cost of Untested Charts

In late 2024, a medium-sized SaaS company experienced a 4-hour outage because a chart update introduced a breaking change in RBAC permissions. The chart had been tested locally with helm install --dry-run, but the dry-run validation doesn’t interact with the API server’s RBAC layer. The deployment succeeded syntactically but failed operationally.

The incident revealed three gaps in their workflow:

  1. No schema validation against the target Kubernetes version
  2. No integration tests in a live cluster
  3. No policy enforcement for security baselines

These gaps are common. According to a 2024 CNCF survey on GitOps practices, fewer than 40% of organizations systematically test Helm charts before production deployment.

The problem is not a lack of tools—it’s understanding which layer each tool addresses.

Testing Layers: What Each Level Validates

Helm chart testing is not a single operation. It requires validation at multiple layers, each catching different classes of errors.

Layer 1: Syntax and Structure Validation

What it catches: Malformed YAML, invalid chart structure, missing required fields

Tools:

  • helm lint: Built-in, minimal validation following Helm best practices
  • yamllint: Strict YAML formatting rules

Example failure caught:

# Invalid indentation breaks the chart
resources:
  limits:
      cpu: "500m"
    memory: "512Mi"  # Incorrect indentation

Limitation: Does not validate whether the rendered manifests are valid Kubernetes objects.

Layer 2: Schema Validation

What it catches: Manifests that would be rejected by the Kubernetes API

Primary tool: kubeconform

Kubeconform is the actively maintained successor to the deprecated kubeval. It validates against OpenAPI schemas for specific Kubernetes versions and can include custom CRDs.

Project Profile:

  • Maintenance: Active, community-driven
  • Strengths: CRD support, multi-version validation, fast execution
  • Why it matters: helm lint validates chart structure, but not if rendered manifests match Kubernetes schemas

Example failure caught:

apiVersion: apps/v1
kind: Deployment
spec:
  replicas: 2
  template:
    metadata:
      labels:
        app: myapp
    spec:
      containers:
      - name: app
        image: nginx:latest
# Missing required field: spec.selector

Configuration example:

helm template my-chart . | kubeconform \
  -kubernetes-version 1.30.0 \
  -schema-location default \
  -schema-location 'https://raw.githubusercontent.com/datreeio/CRDs-catalog/main/{{.Group}}/{{.ResourceKind}}_{{.ResourceAPIVersion}}.json' \
  -summary

Example CI integration:

#!/bin/bash
set -e

KUBE_VERSION="1.30.0"

echo "Rendering chart..."
helm template my-release ./charts/my-chart > manifests.yaml

echo "Validating against Kubernetes $KUBE_VERSION..."
kubeconform \
  -kubernetes-version "$KUBE_VERSION" \
  -schema-location default \
  -summary \
  -output json \
  manifests.yaml | jq -e '.summary.invalid == 0'

Alternative: kubectl --dry-run=server (requires cluster access, validates against actual API server)

Layer 3: Unit Testing

What it catches: Logic errors in templates, incorrect conditionals, wrong value interpolation

Unit tests validate that given a set of input values, the chart produces the expected manifests. This is where template logic is verified before reaching a cluster.

Primary tool: helm-unittest

helm-unittest is the most widely adopted unit testing framework for Helm charts.

Project Profile:

  • GitHub: 3.3k+ stars, ~100 contributors
  • Maintenance: Active (releases every 2-3 months)
  • Primary maintainer: Quentin Machu (originally @QubitProducts, now independent)
  • Commercial backing: None
  • Bus Factor: Medium-High (no institutional backing, but consistent community engagement)

Strengths:

  • Fast execution (no cluster required)
  • Familiar test syntax (similar to Jest/Mocha)
  • Snapshot testing support
  • Good documentation

Limitations:

  • Doesn’t validate runtime behavior
  • Cannot test interactions with admission controllers
  • No validation against actual Kubernetes API

Example test scenario:

# tests/deployment_test.yaml
suite: test deployment
templates:
  - deployment.yaml
tests:
  - it: should set resource limits when provided
    set:
      resources.limits.cpu: "1000m"
      resources.limits.memory: "1Gi"
    asserts:
      - equal:
          path: spec.template.spec.containers[0].resources.limits.cpu
          value: "1000m"
      - equal:
          path: spec.template.spec.containers[0].resources.limits.memory
          value: "1Gi"

  - it: should not create HPA when autoscaling disabled
    set:
      autoscaling.enabled: false
    template: hpa.yaml
    asserts:
      - hasDocuments:
          count: 0

Alternative: Terratest (Helm module)

Terratest is a Go-based testing framework from Gruntwork that includes first-class Helm support. Unlike helm-unittest, Terratest deploys charts to real clusters and allows programmatic assertions in Go.

Example Terratest test:

func TestHelmChartDeployment(t *testing.T) {
    kubectlOptions := k8s.NewKubectlOptions("", "", "default")
    options := &helm.Options{
        KubectlOptions: kubectlOptions,
        SetValues: map[string]string{
            "replicaCount": "3",
        },
    }
    
    defer helm.Delete(t, options, "my-release", true)
    helm.Install(t, options, "../charts/my-chart", "my-release")
    
    k8s.WaitUntilNumPodsCreated(t, kubectlOptions, metav1.ListOptions{
        LabelSelector: "app=my-app",
    }, 3, 30, 10*time.Second)
}

When to use Terratest vs helm-unittest:

  • Use helm-unittest for fast, template-focused validation in CI
  • Use Terratest when you need full integration testing with Go flexibility

Layer 4: Integration Testing

What it catches: Runtime failures, resource conflicts, actual Kubernetes behavior

Integration tests deploy the chart to a real (or ephemeral) cluster and verify it works end-to-end.

Primary tool: chart-testing (ct)

chart-testing is the official Helm project for testing charts in live clusters.

Project Profile:

  • Ownership: Official Helm project (CNCF)
  • Maintainers: Helm team (contributors from Microsoft, IBM, Google)
  • Governance: CNCF-backed with public roadmap
  • LTS: Aligned with Helm release cycle
  • Bus Factor: Low (institutional backing from CNCF provides strong long-term guarantees)

Strengths:

  • De facto standard for public Helm charts
  • Built-in upgrade testing (validates migrations)
  • Detects which charts changed in a PR (efficient for monorepos)
  • Integration with GitHub Actions via official action

Limitations:

  • Requires a live Kubernetes cluster
  • Initial setup more complex than unit testing
  • Does not include security scanning

What ct validates:

  • Chart installs successfully
  • Upgrades work without breaking state
  • Linting passes
  • Version constraints are respected

Example ct configuration:

# ct.yaml
target-branch: main
chart-dirs:
  - charts
chart-repos:
  - bitnami=https://charts.bitnami.com/bitnami
helm-extra-args: --timeout 600s
check-version-increment: true

Typical GitHub Actions workflow:

name: Lint and Test Charts

on: pull_request

jobs:
  lint-test:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v3
        with:
          fetch-depth: 0

      - name: Set up Helm
        uses: azure/setup-helm@v3

      - name: Set up Python
        uses: actions/setup-python@v4
        with:
          python-version: '3.11'

      - name: Set up chart-testing
        uses: helm/chart-testing-action@v2

      - name: Run chart-testing (lint)
        run: ct lint --config ct.yaml

      - name: Create kind cluster
        uses: helm/kind-action@v1

      - name: Run chart-testing (install)
        run: ct install --config ct.yaml

When ct is essential:

  • Public chart repositories (expected by community)
  • Charts with complex upgrade paths
  • Multi-chart repositories with CI optimization needs

Layer 5: Security and Policy Validation

What it catches: Security misconfigurations, policy violations, compliance issues

This layer prevents deploying charts that pass functional tests but violate organizational security baselines or contain vulnerabilities.

Policy Enforcement: Conftest (Open Policy Agent)

Conftest is the CLI interface to Open Policy Agent for policy-as-code validation.

Project Profile:

  • Parent: Open Policy Agent (CNCF Graduated Project)
  • Governance: Strong CNCF backing, multi-vendor support
  • Production adoption: Netflix, Pinterest, Goldman Sachs
  • Bus Factor: Low (graduated CNCF project with multi-vendor backing)

Strengths:

  • Policies written in Rego (reusable, composable)
  • Works with any YAML/JSON input (not Helm-specific)
  • Can enforce organizational standards programmatically
  • Integration with admission controllers (Gatekeeper)

Limitations:

  • Rego has a learning curve
  • Does not replace functional testing

Example Conftest policy:

# policy/security.rego
package main

import future.keywords.contains
import future.keywords.if
import future.keywords.in

deny[msg] {
  input.kind == "Deployment"
  container := input.spec.template.spec.containers[_]
  not container.resources.limits.memory
  msg := sprintf("Container '%s' must define memory limits", [container.name])
}

deny[msg] {
  input.kind == "Deployment"
  container := input.spec.template.spec.containers[_]
  not container.resources.limits.cpu
  msg := sprintf("Container '%s' must define CPU limits", [container.name])
}

Running the validation:

helm template my-chart . | conftest test -p policy/ -

Alternative: Kyverno

Kyverno offers policy enforcement using native Kubernetes manifests instead of Rego. Policies are written in YAML and can validate, mutate, or generate resources.

Example Kyverno policy:

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: require-resource-limits
spec:
  validationFailureAction: Enforce
  rules:
  - name: check-container-limits
    match:
      resources:
        kinds:
        - Pod
    validate:
      message: "All containers must have CPU and memory limits"
      pattern:
        spec:
          containers:
          - resources:
              limits:
                memory: "?*"
                cpu: "?*"

Conftest vs Kyverno:

  • Conftest: Policies run in CI, flexible for any YAML
  • Kyverno: Runtime enforcement in-cluster, Kubernetes-native

Both can coexist: Conftest in CI for early feedback, Kyverno in cluster for runtime enforcement.

Vulnerability Scanning: Trivy

Trivy by Aqua Security provides comprehensive security scanning for Helm charts.

Project Profile:

  • Maintainer: Aqua Security (commercial backing with open-source core)
  • Scope: Vulnerability scanning + misconfiguration detection
  • Helm integration: Official trivy helm command
  • Bus Factor: Low (commercial backing + strong open-source adoption)

What Trivy scans in Helm charts:

  1. Vulnerabilities in referenced container images
  2. Misconfigurations (similar to Conftest but pre-built rules)
  3. Secrets accidentally committed in templates

Example scan:

trivy helm ./charts/my-chart --severity HIGH,CRITICAL --exit-code 1

Sample output:

myapp/templates/deployment.yaml (helm)
====================================

Tests: 12 (SUCCESSES: 10, FAILURES: 2)
Failures: 2 (HIGH: 1, CRITICAL: 1)

HIGH: Container 'app' of Deployment 'myapp' should set 'securityContext.runAsNonRoot' to true
════════════════════════════════════════════════════════════════════════════════════════════════
Ensure containers run as non-root users

See https://kubernetes.io/docs/concepts/security/pod-security-standards/
────────────────────────────────────────────────────────────────────────────────────────────────
 myapp/templates/deployment.yaml:42

Commercial support:
Aqua Security offers Trivy Enterprise with advanced features (centralized scanning, compliance reporting). For most teams, the open-source version is sufficient.

Other Security Tools

Polaris (Fairwinds)

Polaris scores charts based on security and reliability best practices. Unlike enforcement tools, it provides a health score and actionable recommendations.

Use case: Dashboard for chart quality across a platform

Checkov (Bridgecrew/Palo Alto)

Similar to Trivy but with a broader IaC focus (Terraform, CloudFormation, Kubernetes, Helm). Pre-built policies for compliance frameworks (CIS, PCI-DSS).

When to use Checkov:

  • Multi-IaC environment (not just Helm)
  • Compliance-driven validation requirements

Enterprise Selection Criteria

Bus Factor and Long-Term Viability

For production infrastructure, tool sustainability matters as much as features. Community support channels like Helm CNCF Slack (#helm-users, #helm-dev) and CNCF TAG Security provide valuable insights into which projects have active maintainer communities.

Questions to ask:

  • Is the project backed by a foundation (CNCF, Linux Foundation)?
  • Are multiple companies contributing?
  • Is the project used in production by recognizable organizations?
  • Is there a public roadmap?

Risk Classification:

Tool Governance Bus Factor Notes
chart-testing CNCF Low Helm official project
Conftest/OPA CNCF Graduated Low Multi-vendor backing
Trivy Aqua Security Low Commercial backing + OSS
kubeconform Community Medium Active, but single maintainer
helm-unittest Community Medium-High No institutional backing
Polaris Fairwinds Medium Company-sponsored OSS

Kubernetes Version Compatibility

Tools must explicitly support the Kubernetes versions you run in production.

Red flags:

  • No documented compatibility matrix
  • Hard-coded dependencies on old K8s versions
  • No testing against multiple K8s versions in CI

Example compatibility check:

# Does the tool support your K8s version?
kubeconform --help | grep -A5 "kubernetes-version"

For tools like ct, always verify they test against a matrix of Kubernetes versions in their own CI.

Commercial Support Options

When commercial support matters:

  • Regulatory compliance requirements (SOC2, HIPAA, etc.)
  • Limited internal expertise
  • SLA-driven operations

Available options:

  • Trivy: Aqua Security offers Trivy Enterprise
  • OPA/Conftest: Styra provides OPA Enterprise
  • Terratest: Gruntwork offers consulting and premium modules

Most teams don’t need commercial support for chart testing specifically, but it’s valuable in regulated industries where audits require vendor SLAs.

Security Scanner Integration

For enterprise pipelines, chart testing tools should integrate cleanly with:

  • SIEM/SOAR platforms
  • CI/CD notification systems
  • Security dashboards (e.g., Grafana, Datadog)

Required features:

  • Structured output formats (JSON, SARIF)
  • Exit codes for CI failure
  • Support for custom policies
  • Webhook or API for event streaming

Example: Integrating Trivy with SIEM

# .github/workflows/security.yaml
- name: Run Trivy scan
  run: trivy helm ./charts --format json --output trivy-results.json

- name: Send to SIEM
  run: |
    curl -X POST https://siem.company.com/api/events \
      -H "Content-Type: application/json" \
      -d @trivy-results.json

Testing Pipeline Architecture

A production-grade Helm chart pipeline combines multiple layers:

Pipeline efficiency principles:

  1. Fail fast: syntax and schema errors should never reach integration tests
  2. Parallel execution where possible (unit tests + security scans)
  3. Cache ephemeral cluster images to reduce setup time
  4. Skip unchanged charts (ct built-in change detection)

Decision Matrix: When to Use What

Scenario 1: Small Team / Early-Stage Startup

Requirements: Minimal overhead, fast iteration, reasonable safety

Recommended Stack:

Linting:      helm lint + yamllint
Validation:   kubeconform
Security:     trivy helm

Optional: helm-unittest (if template logic becomes complex)

Rationale: Zero-dependency baseline that catches 80% of issues without operational complexity.

Scenario 2: Enterprise with Compliance Requirements

Requirements: Auditable, comprehensive validation, commercial support available

Recommended Stack:

Linting:      helm lint + yamllint
Validation:   kubeconform
Unit Tests:   helm-unittest
Security:     Trivy Enterprise + Conftest (custom policies)
Integration:  chart-testing (ct)
Runtime:      Kyverno (admission control)

Optional: Terratest for complex upgrade scenarios

Rationale: Multi-layer defense with both pre-deployment and runtime enforcement. Commercial support available for security components.

Scenario 3: Multi-Tenant Internal Platform

Requirements: Prevent bad charts from affecting other tenants, enforce standards at scale

Recommended Stack:

CI Pipeline:
  • helm lint → kubeconform → helm-unittest → ct
  • Conftest (enforce resource quotas, namespaces, network policies)
  • Trivy (block critical vulnerabilities)

Runtime:
  • Kyverno or Gatekeeper (enforce policies at admission)
  • ResourceQuotas per namespace
  • NetworkPolicies by default

Additional tooling:

  • Polaris dashboard for chart quality scoring
  • Custom admission webhooks for platform-specific rules

Rationale: Multi-tenant environments cannot tolerate “soft” validation. Runtime enforcement is mandatory.

Scenario 4: Open Source Public Charts

Requirements: Community trust, transparent testing, broad compatibility

Recommended Stack:

Must-have:
  • chart-testing (expected standard)
  • Public CI (GitHub Actions with full logs)
  • Test against multiple K8s versions

Nice-to-have:
  • helm-unittest with high coverage
  • Automated changelog generation
  • Example values for common scenarios

Rationale: Public charts are judged by testing transparency. Missing ct is a red flag for potential users.

The Minimum Viable Testing Stack

For any environment deploying Helm charts to production, this is the baseline:

Layer 1: Pre-Commit (Developer Laptop)

helm lint charts/my-chart
yamllint charts/my-chart

Layer 2: CI Pipeline (Automated on PR)

# Fast validation
helm template my-chart ./charts/my-chart | kubeconform \
  -kubernetes-version 1.30.0 \
  -summary

# Security baseline
trivy helm ./charts/my-chart --exit-code 1 --severity CRITICAL,HIGH

Layer 3: Pre-Production (Staging Environment)

# Integration test with real cluster
ct install --config ct.yaml --charts charts/my-chart

Time investment:

  • Initial setup: 4-8 hours
  • Per-PR overhead: 3-5 minutes
  • Maintenance: ~1 hour/month

ROI calculation:

Average production incident caused by untested chart:

  • Detection: 15 minutes
  • Triage: 30 minutes
  • Rollback: 20 minutes
  • Post-mortem: 1 hour
  • Total: ~2.5 hours of engineering time

If chart testing prevents even one incident per quarter, it pays for itself in the first month.

Common Anti-Patterns to Avoid

Anti-Pattern 1: Only using --dry-run

helm install --dry-run validates syntax but skips:

  • Admission controller logic
  • RBAC validation
  • Actual resource creation

Better: Combine dry-run with kubeconform and at least one integration test.

Anti-Pattern 2: Testing only in production-like clusters

“We test in staging, which is identical to production.”

Problem: Staging clusters rarely match production exactly (node counts, storage classes, network policies). Integration tests should run in isolated, ephemeral environments.

Anti-Pattern 3: Security scanning without enforcement

Running trivy helm without failing the build on critical findings is theater.

Better: Set --exit-code 1 and enforce in CI.

Anti-Pattern 4: Ignoring upgrade paths

Most chart failures happen during upgrades, not initial installs. Chart-testing addresses this with ct install --upgrade.

Conclusion: Testing is Infrastructure Maturity

The gap between teams that test Helm charts and those that don’t is not about tooling availability—it’s about treating infrastructure code with the same discipline as application code.

The cost of testing is measured in minutes per PR. The cost of not testing is measured in hours of production incidents, eroded trust in automation, and teams reverting to manual deployments because “Helm is too risky.”

The testing stack you choose matters less than the fact that you have one. Start with the minimal viable stack (lint + schema + security), run it consistently, and expand as your charts become more complex.

By implementing a structured testing pipeline, you catch 95% of chart issues before they reach production. The remaining 5% are edge cases that require production observability, not more testing layers.

Helm chart testing is not about achieving perfection—it’s about eliminating the preventable failures that undermine confidence in your deployment pipeline.

Frequently Asked Questions (FAQ)

What is Helm chart testing and why is it important in production?

Helm chart testing ensures that Kubernetes manifests generated from Helm templates are syntactically correct, schema-compliant, secure, and function correctly when deployed. In production, untested charts can cause outages, security incidents, or failed upgrades, even if application code itself is stable.

Is helm lint enough to validate a Helm chart?

No. helm lint only validates chart structure and basic best practices. It does not validate rendered manifests against Kubernetes API schemas, test template logic, or verify runtime behavior. Production-grade testing requires additional layers such as schema validation, unit tests, and integration tests.

What is the difference between Helm unit tests and integration tests?

Unit tests (e.g., using helm-unittest) validate template logic by asserting expected output for given input values without deploying anything. Integration tests (e.g., using chart-testing or Terratest) deploy charts to a real Kubernetes cluster and validate runtime behavior, upgrades, and interactions with the API server.

Which tools are recommended for validating Helm charts against Kubernetes schemas?

The most commonly recommended tool is kubeconform, which validates rendered manifests against Kubernetes OpenAPI schemas for specific Kubernetes versions and supports CRDs. An alternative is kubectl --dry-run=server, which validates against a live API server.

How can Helm chart testing prevent production outages?

Testing catches common failure modes before deployment, such as missing selectors in Deployments, invalid RBAC permissions, incorrect conditionals, or incompatible API versions. Many production outages originate from configuration and chart logic errors rather than application bugs.

What is the role of security scanning in Helm chart testing?

Security scanning detects misconfigurations, policy violations, and vulnerabilities that functional tests may miss. Tools like Trivy and Conftest (OPA) help enforce security baselines, prevent unsafe defaults, and block deployments that violate organizational or compliance requirements.

Is chart-testing (ct) required for private Helm charts?

While not strictly required, chart-testing is highly recommended for any chart deployed to production. It is considered the de facto standard for integration testing, especially for charts with upgrades, multiple dependencies, or shared cluster environments.

What is the minimum viable Helm testing pipeline for CI?

At a minimum, a production-ready pipeline should include:
helm lint for structural validation
kubeconform for schema validation
trivy helm for security scanning
Integration tests can be added as charts grow in complexity or criticality.

Helm Drivers Explained: Secrets, ConfigMaps, and State Storage in Helm

Helm Drivers Explained: Secrets, ConfigMaps, and State Storage in Helm

When working seriously with Helm in production environments, one of the less-discussed but highly impactful topics is how Helm stores and manages release state. This is where Helm drivers come into play. Understanding Helm drivers is not just an academic exercise; it directly affects security, scalability, troubleshooting, and even disaster recovery strategies.

Understanding Helm drivers is critical for production deployments. This is just one of many essential topics covered in our comprehensive Helm package management guide.

What Helm Drivers Are and How They Are Configured

A Helm driver defines the backend storage mechanism Helm uses to persist release information such as manifests, values, and revision history. Every Helm release has state, and that state must live somewhere. The driver determines where and how this data is stored.

Helm drivers are configured using the HELM_DRIVER environment variable. If the variable is not explicitly set, Helm defaults to using Kubernetes Secrets.

export HELM_DRIVER=secrets

This simple configuration choice can have deep operational consequences, especially in regulated environments or large-scale clusters.

Available Helm Drivers

Secrets Driver (Default)

The secrets driver stores release information as Kubernetes Secrets in the target namespace. This has been the default driver since Helm 3 was introduced.

Secrets are base64-encoded and can be encrypted at rest if Kubernetes encryption at rest is enabled. This makes the driver suitable for clusters with moderate security requirements without additional configuration.

ConfigMaps Driver

The configmaps driver stores Helm release state as Kubernetes ConfigMaps. Functionally, it behaves very similarly to the secrets driver but without any form of implicit confidentiality.

export HELM_DRIVER=configmaps

This driver is often used in development or troubleshooting scenarios where human readability is preferred.

Memory Driver

The memory driver stores release information only in memory. Once the Helm process exits, all state is lost.

export HELM_DRIVER=memory

This driver is rarely used outside of testing, CI pipelines, or ephemeral validation workflows.

Evolution of Helm Drivers

Helm drivers were significantly reworked with the release of Helm 3 in late 2019. Helm 2 relied on Tiller and ConfigMaps by default, which introduced security and operational complexity. Helm 3 removed Tiller entirely and introduced pluggable storage backends with Secrets as the secure default.

Since then, improvements have focused on performance, stability, and better error handling rather than introducing new drivers. The core abstraction has remained intentionally small to avoid fragmentation.

Practical Use Cases and When to Use Each Driver

In production Kubernetes clusters, the secrets driver is almost always the right choice. It integrates naturally with RBAC, supports encryption at rest, and aligns with Kubernetes-native security models.

ConfigMaps can be useful when debugging failed upgrades or learning Helm internals, as the stored data is easier to inspect. However, it should be avoided in environments handling sensitive values.

The memory driver shines in CI/CD pipelines where chart validation or rendering is needed without polluting a cluster with state.

Practical Examples

Switching drivers dynamically can be useful when inspecting a release:

HELM_DRIVER=configmaps helm get manifest my-release

Or running a dry validation in CI:

HELM_DRIVER=memory helm upgrade --install test ./chart --dry-run

Final Thoughts

Helm drivers are rarely discussed, yet they influence how reliable, secure, and observable your Helm workflows are. Treating the choice of driver as a deliberate architectural decision rather than a default setting is one of those small details that differentiate mature DevOps practices from ad-hoc automation.

Inspecting Release Storage Directly

Knowing where releases live makes debugging concrete. Each revision is one Secret:

# List every revision of every release in a namespace
kubectl get secrets -n myns -l "owner=helm" \
  -o custom-columns=NAME:.metadata.name,STATUS:.metadata.labels.status

# Decode what revision 4 actually contains (double base64 + gzip)
kubectl get secret sh.helm.release.v1.myrelease.v4 -n myns \
  -o jsonpath='{.data.release}' | base64 -d | base64 -d | gunzip | jq .name,.info.status

That double base64 -d is not a typo — Helm base64-encodes the payload, and Kubernetes base64-encodes Secret data on top. The decoded JSON contains the chart, values and manifests of that revision: it is the raw material behind helm get.

Two operational settings to pair with this. --history-max (default 10) caps stored revisions per release — on clusters with frequent upgrades, lowering it is the cheapest etcd diet available. And if you need the SQL driver, it is an environment variable away: HELM_DRIVER=sql plus HELM_DRIVER_SQL_CONNECTION_STRING="postgresql://user:pass@host/helm?sslmode=require" — remembering that releases stored in Secrets do not migrate over automatically.

Frequently Asked Questions

Where does Helm store release information?

In the cluster, as Secrets (default) in the release’s namespace — one per revision, named sh.helm.release.v1.<name>.v<revision>. The driver is configurable via HELM_DRIVER: secret, configmap or sql (PostgreSQL).

When should I use the SQL storage backend for Helm?

When release metadata outgrows what etcd should be storing: very large releases (the 1MB Secret size limit is a hard wall), strict audit requirements, or hundreds of frequently-upgraded releases whose revision history bloats etcd. The tradeoff is an external PostgreSQL dependency your tooling must reach.

Why am I hitting the 1MB limit on Helm releases, and what can I do?

The whole release (chart + values + manifests, gzipped) must fit in one Secret, and etcd caps object size at ~1MB. Fixes in order: slim the chart (drop bundled CRDs/files with .helmignore), reduce revision history with --history-max, or move to the SQL driver, which has no such limit.

Can I switch Helm storage drivers on an existing cluster?

There is no built-in migration — the drivers store state in different places, so a release installed under one driver is invisible to another. Migrating means exporting/re-adopting releases or letting them roll over naturally. Choose the driver before standardising your platform.

Helm 4.0 Features, Breaking Changes & Migration Guide 2025

Helm 4.0 Features, Breaking Changes & Migration Guide 2025

Helm is one of the main utilities within the Kubernetes ecosystem, and therefore the release of a new major version, such as Helm 4.0, is something to consider because it is undoubtedly something that will need to be analyzed, evaluated, and managed in the coming months.

Related reading: Helm version constraints explained.

Related reading: Helm hooks: lifecycle, weights and deletion policies.

Helm 4.0 represents a major milestone in Kubernetes package management. For a complete understanding of Helm from basics to advanced features, explore our .

Due to this, we will see many comments and articles around this topic, so we will try to shed some light.

Helm 4.0 Key Features and Improvements

According to the project itself in its announcement, Helm 4 introduces three major blocks of changes: new plugin system, better integration with Kubernetes ** and internal modernization of SDK and performance**.

New Plugin System (includes WebAssembly)

The plugin system has been completely redesigned, with a special focus on security through the introduction of a new WebAssembly runtime that, while optional, is recommended as it runs in a “sandbox” mode that offers limits and guarantees from a security perspective.

In any case, there is no need to worry excessively, as the “classic” plugins continue to work, but the message is clear: for security and extensibility, the direction is Wasm.

Server-Side Apply and Better Integration with Other Controllers

From this version, Helm 4 supports Server-Side Apply (SSA) through the --server-side flag, which has already become stable since Kubernetes version v1.22 and allows updates on objects to be handled server-side to avoid conflicts between different controllers managing the same resources.

It also incorporates integration with kstatus to ensure the state of a component in a more reliable way than what currently happens with the use of the --wait parameter.

Other Additional Improvements

Additionally, there is another list of improvements that, while of lesser scope, are important qualitative leaps, such as the following:

  • Installation by digest in OCI registries: (helm install myapp oci://...@sha256:<digest>)
  • Multi-document values: you can pass multiple YAML values in a single multi-doc file, facilitating complex environments/overlays.
  • New --set-json argument that allows for easily passing complex structures compared to the current solution using the --set parameter

Why a Major (v4) and Not Another Minor of 3.x?

As explained in the official release post, there were features that the team could not introduce in v3 without breaking public SDK APIs and internal architecture:

  • Strong change in the plugin system (WebAssembly, new types, deep integration with the core).
  • Restructuring of Go packages and establishment of a stable SDK at helm.sh/helm/v4, code-incompatible with v3.
  • Introduction and future evolution of Charts v3, which require the SDK to support multiple versions of chart APIs.

With all this, continuing in the 3.x branch would have violated SemVer: the major number change is basically “paying” the accumulated technical debt to be able to move forward.

Additionally, a new evolution of the charts is expected in the future, moving from v2 to a future v3 that is not yet fully defined, and currently, v2 charts run correctly in this new version.

Is Helm 4.0 Migration Required?

The short answer is: yes. And possibly the long answer is: yes, and quickly. In the official Helm 4 announcement, they specify the support schedule for Helm 3:

  • Helm 3 bug fixes until July 8, 2026.
  • Helm 3 security fixes until November 11, 2026.
  • No new features will be backported to Helm 3 during this period; only Kubernetes client libraries will be updated to support new K8s versions.

Practical translation:

  • Organizations have approximately 1 year to plan a smooth Helm 4.0 migration with continued bug support for Helm 3.
  • After November 2026, continuing to use Helm 3 will become increasingly risky from a security and compatibility standpoint.

Best Practices for Migration

To carry out the migration, it is important to remember that it is perfectly possible and feasible to have both versions installed on the same machine or agent, so a “gradual” migration can be done to ensure that the end of support for version v3 is reached with everything migrated correctly, and for that, the following steps are recommended:

  • Conduct an analysis of all Helm commands and usage from the perspective of integration pipelines, upgrade scripts, or even the import of Helm client libraries in Helm-based developments.
  • Especially carefully review all uses of --post-renderer, helm registry login, --atomic, --force.
  • After the analysis, start testing Helm 4 first in non-production environments, reusing the same charts and values, reverting to Helm 3 if a problem is detected until it is resolved.
  • If you have critical plugins, explicitly test them with Helm 4 before making the global change.

What are the main new features in Helm 4.0?

Helm 4.0 introduces three major improvements: a redesigned plugin system with WebAssembly support for enhanced security, Server-Side Apply (SSA) integration for better conflict resolution, and internal SDK modernization for improved performance. Additional features include OCI digest installation and multi-document values support.

When does Helm 3 support end?

Helm 3 bug fixes end July 8, 2026 and security fixes end November 11, 2026. No new features will be backported to Helm 3. Organizations should plan migration to Helm 4.0 before November 2026 to avoid security and compatibility risks.

Are Helm 3 charts compatible with Helm 4.0?

Yes, Helm Chart API v2 charts work correctly with Helm 4.0. However, the Go SDK has breaking changes, so applications using Helm libraries need code updates. The CLI commands remain largely compatible for most use cases.

Can I run Helm 3 and Helm 4 simultaneously?

Yes, both versions can be installed on the same machine, enabling gradual migration strategies. This allows teams to test Helm 4.0 in non-production environments while maintaining Helm 3 for critical workloads during the transition period.

What should I test before migrating to Helm 4.0?

Focus on testing critical plugins, post-renderers, and specific flags like --atomic, --force, and helm registry login. Test all charts and values in non-production environments first, and review any custom integrations using Helm SDK libraries.

What is Server-Side Apply in Helm 4.0?

Server-Side Apply (SSA) is enabled with the --server-side flag and handles resource updates on the Kubernetes API server side. This prevents conflicts between different controllers managing the same resources and has been stable since Kubernetes v1.22.

Frequently Asked Questions

Do I have to migrate my releases from Helm 3 to Helm 4?

Existing releases keep working — Helm 4 reads the same release storage format. The migration work is in your tooling: plugin compatibility, CI images pinned to the helm binary, and any scripts relying on removed flags or changed output.

Are Helm 3 charts compatible with Helm 4?

Overwhelmingly yes — apiVersion: v2 charts render unchanged. What breaks is tooling around charts, not the charts themselves: plugins built for Helm 3’s plugin API need updates, and deprecated CLI behaviors are removed.

Can Helm 3 and Helm 4 coexist on the same machine?

Yes — they are separate binaries. Keep them as helm3/helm4 during migration and point CI at the explicit binary. They share release storage in the cluster, so a release installed with one is visible to the other.

What is the biggest breaking change in Helm 4?

The plugin system overhaul: Helm 3 plugins do not run under Helm 4 without being ported. If your workflow leans on plugins (diff, secrets, s3…), verify each has a Helm 4 release before switching your pipelines.

Helm v3.17 Take Ownership Flag: Fix Release Conflicts

Helm v3.17 Take Ownership Flag: Fix Release Conflicts

Helm has long been the standard for managing Kubernetes applications using packaged charts, bringing a level of reproducibility and automation to the deployment process. However, some operational tasks, such as renaming a release or migrating objects between charts, have traditionally required cumbersome workarounds. With the introduction of the --take-ownership flag in Helm v3.17 (released in January 2025), a long-standing pain point is finally addressed—at least partially.

Related reading: Helm dependencies: condition, alias and version rules.

The take-ownership feature represents the continuing evolution of Helm. Learn about this and other cutting-edge capabilities in our Helm Charts Package Management Guide

In this post, we will explore:

  • What the --take-ownership flag does
  • Why it was needed
  • The caveats and limitations
  • Real-world use cases where it helps
  • When not to use it

Understanding Helm Release Ownership and Object Management

When Helm installs or upgrades a chart, it injects metadata—labels and annotations—into every managed Kubernetes object. These include:

app.kubernetes.io/managed-by: Helm
meta.helm.sh/release-name: my-release
meta.helm.sh/release-namespace: default

This metadata serves an important role: Helm uses it to track and manage resources associated with each release. As a safeguard, Helm does not allow another release to modify objects it does not own and when you trying that you will see messages like the one below:

Error: Unable to continue with install: Service "provisioner-agent" in namespace "test-my-ns" exists and cannot be imported into the current release: invalid ownership metadata; annotation validation error: key "meta.helm.sh/release-name" must equal "dp-core-infrastructure11": current value is "dp-core-infrastructure"

While this protects users from accidental overwrites, it creates limitations for advanced use cases.

Why --take-ownership Was Needed

Let’s say you want to:

  • Rename an existing Helm release from api-v1 to api.
  • Move a ConfigMap or Service from one chart to another.
  • Rebuild state during GitOps reconciliation when previous Helm metadata has drifted.

Previously, your only option was to:

  1. Uninstall the existing release.
  2. Reinstall under the new name.

This approach introduces downtime, and in production systems, that’s often not acceptable.

What the Flag Does

helm upgrade my-release ./my-chart --take-ownership

When this flag is passed, Helm will:

  • Skip the ownership validation for existing objects.
  • Override the labels and annotations to associate the object with the current release.

In practice, this allows you to claim ownership of resources that previously belonged to another release, enabling seamless handovers.

⚠️ What It Doesn’t Do

This flag does not:

  • Clean up references from the previous release.
  • Protect you from future uninstalls of the original release (which might still remove shared resources).
  • Allow you to adopt completely unmanaged Kubernetes resources (those not initially created by Helm).

In short, it’s a mechanism for bypassing Helm’s ownership checks, not a full lifecycle manager.

Real-World Helm Take Ownership Use Cases

Let’s go through common scenarios where this feature is useful.

✅ 1. Renaming a Release Without Downtime

Before:

helm uninstall old-name
helm install new-name ./chart

Now:

helm upgrade new-name ./chart --take-ownership

✅ 2. Migrating Objects Between Charts

You’re refactoring a large chart into smaller, modular ones and need to reassign certain Service or Secret objects.

This flag allows the new release to take control of the object without deleting or recreating it.

✅ 3. GitOps Drift Reconciliation

If objects were deployed out-of-band or their metadata changed unintentionally, GitOps tooling using Helm can recover without manual intervention using --take-ownership.

Best Practices and Recommendations

  • Use this flag intentionally, and document where it’s applied.
  • If possible, remove the previous release after migration to avoid confusion.
  • Monitor Helm’s behavior closely when managing shared objects.
  • For non-Helm-managed resources, continue to use kubectl annotate or kubectl label to manually align metadata.

Conclusion

The --take-ownership flag is a welcomed addition to Helm’s CLI arsenal. While not a universal solution, it smooths over many of the rough edges developers and SREs face during release evolution and GitOps adoption.

It brings a subtle but powerful improvement—especially in complex environments where resource ownership isn’t static.

Stay updated with Helm releases, and consider this flag your new ally in advanced release engineering.

The Modern Way: helm upgrade –take-ownership

Since Helm 3.17, most of the manual adoption workflow below is one flag:

helm upgrade --install myrelease ./mychart --take-ownership

With --take-ownership, Helm skips the ownership check that normally fails with “invalid ownership metadata; annotation validation error” and rewrites the ownership metadata to point at the new release. The resources’ spec is untouched — only the Helm bookkeeping changes. Two caveats: it applies to every conflicting resource in the chart (there is no per-resource opt-in), and it will happily steal resources from another Helm release, not just from kubectl-created objects — so run it with --dry-run first in anything shared.

Manual Adoption: The Three Ownership Markers

On older Helm versions (or when you want per-resource control), adoption means setting the three markers Helm checks before managing a resource:

kubectl label   deployment myapp "app.kubernetes.io/managed-by=Helm"
kubectl annotate deployment myapp "meta.helm.sh/release-name=myrelease"
kubectl annotate deployment myapp "meta.helm.sh/release-namespace=default"

After those three, the next helm upgrade treats the resource as its own and reconciles it against the chart template. This is the route to migrate kubectl-managed or kustomize-managed infrastructure into a chart incrementally, one resource at a time, without deleting anything.

Frequently Asked Questions

What does the Helm u002du002dtake-ownership flag do?

The u003ccodeu003eu002du002dtake-ownershipu003c/codeu003e flag allows Helm to bypass ownership validation and claim control of Kubernetes resources that belong to another release. It updates the u003cstrongu003emeta.helm.sh/release-nameu003c/strongu003e annotation to associate objects with the current release, enabling zero-downtime release renames and chart migrations.

When should I use Helm take ownership?

Use u003ccodeu003eu002du002dtake-ownershipu003c/codeu003e when renaming releases without downtime, migrating objects between charts, or fixing GitOps drift. It’s ideal for u003cstrongu003eproduction environmentsu003c/strongu003e where uninstall/reinstall cycles aren’t acceptable. Always document usage and clean up previous releases afterward.

What are the limitations of Helm take ownership?

The flag u003cstrongu003edoesn’t clean upu003c/strongu003e references from previous releases or protect against future uninstalls of the original release. It only works with Helm-managed resources, not completely unmanaged Kubernetes objects. Manual cleanup of old releases is still required.

Is Helm take ownership safe for production use?

Yes, but use it u003cstrongu003eintentionally and carefullyu003c/strongu003e. The flag bypasses Helm’s safety checks, so ensure you understand the ownership implications. Test in staging first, document all usage, and monitor for conflicts. Remove old releases after successful migration to avoid confusion.

Which Helm version introduced the take ownership flag?

The u003ccodeu003eu002du002dtake-ownershipu003c/codeu003e flag was introduced in u003cstrongu003eHelm v3.17u003c/strongu003e, released in January 2025. This feature addresses long-standing pain points with release renaming and chart migrations that previously required downtime-inducing uninstall/reinstall cycles.

Advanced Helm Commands and Flags Every Kubernetes Engineer Should Know

Advanced Helm Commands and Flags Every Kubernetes Engineer Should Know

Managing Kubernetes resources effectively can sometimes feel overwhelming, but Helm, the Kubernetes package manager, offers several commands and flags that make the process smoother and more intuitive. In this article, we’ll dive into some lesser-known Helm commands and flags, explaining their uses, benefits, and practical examples.

These advanced commands are essential for mastering Helm in production. For the complete toolkit including fundamentals, testing, and deployment patterns, visit our Helm package management guide.

1. helm get values: Retrieving Deployed Chart Values

The helm get values command is essential when you need to see the configuration values of a deployed Helm chart. This is particularly useful when you have a chart deployed but lack access to its original configuration file. With this command, you can achieve an “Infrastructure as Code” approach by capturing the current state of your deployment.

Usage:

helm get values <release-name> [flags]

Example:

To get the values of a deployed chart named my-release:

helm get values my-release --namespace my-namespace

This command outputs the current values used for the deployment, which is valuable for documentation, replicating the environment, or modifying deployments.

2. Understanding helm upgrade Flags: --reset-values, --reuse-values, and --reset-then-reuse

The helm upgrade command is typically used to upgrade or modify an existing Helm release. However, the behavior of this command can be finely tuned using several flags: --reset-values, --reuse-values, and --reset-then-reuse.

  • --reset-values: Ignores the previous values and uses only the values provided in the current command. Use this flag when you want to override the existing configuration entirely.

Example Scenario: You are deploying a new version of your application, and you want to ensure that no old values are retained.

  helm upgrade my-release my-chart --reset-values --set newKey=newValue
  • --reuse-values: Reuses the previous release’s values and merges them with any new values provided. This flag is useful when you want to keep most of the old configuration but apply a few tweaks.

Example Scenario: You need to add a new environment variable to an existing deployment without affecting the other settings.

  helm upgrade my-release my-chart --reuse-values --set newEnv=production
  • --reset-then-reuse: A combination of the two. It resets to the original values and then merges the old values back, allowing you to start with a clean slate while retaining specific configurations.

Example Scenario: Useful in complex environments where you want to ensure the chart is using the original default settings but retain some custom values.

  helm upgrade my-release my-chart --reset-then-reuse --set version=2.0

3. helm lint: Ensuring Chart Quality in CI/CD Pipelines

The helm lint command checks Helm charts for syntax errors, best practices, and other potential issues. This is especially useful when integrating Helm into a CI/CD pipeline, as it ensures your charts are reliable and adhere to best practices before deployment.

Usage:

helm lint <chart-path> [flags]
  • <chart-path>: Path to the Helm chart you want to validate.

Example:

helm lint ./my-chart/

This command scans the my-chart directory for issues like missing fields, incorrect YAML structure, or deprecated usage. If you’re automating deployments, integrating helm lint into your pipeline helps catch problems early. By adding this command in your CICD pipeline, you ensure that any syntax or structural issues are caught before proceeding to build or deployment stages. You can lear more about helm testing in the linked article

4. helm rollback: Reverting to a Previous Release

The helm rollback command allows you to revert a release to a previous version. This can be incredibly useful in case of a failed upgrade or deployment, as it provides a way to quickly restore a known good state.

Usage:

helm rollback <release-name> [revision] [flags]
  • [revision]: The revision number to which you want to roll back. If omitted, Helm will roll back to the previous release by default.

Example:

To roll back a release named my-release to its previous version:

helm rollback my-release

To roll back to a specific revision, say revision 3:

helm rollback my-release 3

This command can be a lifesaver when a recent change breaks your application, allowing you to quickly restore service continuity while investigating the issue.

5. helm verify: Validating a Chart Before Use

The helm verify command checks the integrity and validity of a chart before it is deployed. This command ensures that the chart’s package file has not been tampered with or corrupted. It’s particularly useful when you are pulling charts from external repositories or using charts shared across multiple teams.

Usage:

helm verify <chart-path>

Example:

To verify a downloaded chart named my-chart:

helm verify ./my-chart.tgz

If the chart passes the verification, Helm will output a success message. If it fails, you’ll see details of the issues, which could range from missing files to checksum mismatches.

Conclusion

Leveraging these advanced Helm commands and flags can significantly enhance your Kubernetes management capabilities. Whether you are retrieving existing deployment configurations, fine-tuning your Helm upgrades, or ensuring the quality of your charts in a CI/CD pipeline, these tricks help you maintain a robust and efficient Kubernetes environment.

Five More Flags Worth Knowing

helm template --show-only templates/deployment.yaml renders a single template instead of the whole chart — the fastest way to debug one manifest without scrolling through hundreds of lines.

helm get manifest myrelease --revision 3 shows exactly what revision 3 deployed. Combined with diff <(helm get manifest r --revision 3) <(helm get manifest r --revision 4) you get a precise answer to “what changed between these two upgrades”.

helm status myrelease -o json makes release state scriptable — jq -r .info.status in CI gates is cleaner than parsing human output.

helm lint --strict turns warnings into errors. Without --strict, lint passes charts that will annoy you later; in CI there is no reason not to use it.

--post-renderer pipes Helm’s rendered output through any executable before applying — the standard escape hatch for applying kustomize patches to third-party charts you do not control, without forking them.

And one plugin that saves upgrades: mapkubeapis (helm plugin install https://github.com/helm/helm-mapkubeapis) rewrites release metadata that references Kubernetes APIs removed in newer versions — the fix for upgrades failing with “unable to build kubernetes objects” after a cluster upgrade.

Frequently Asked Questions

How do I see what a Helm release will change before applying it?

Use helm diff upgrade (from the helm-diff plugin) to see a rendered diff against the live release, or helm upgrade --dry-run --debug for the rendered manifests. For a diff against what is actually running in the cluster, helm get manifest piped to kubectl diff -f - works without plugins.

What does helm upgrade –reuse-values actually do?

It merges your new --set/-f overrides on top of the values stored from the previous release, ignoring any changes in the chart’s default values.yaml. That last part surprises people: after a chart version bump, new defaults do not apply. Prefer --reset-then-reuse-values or explicit values files in CI.

How can I roll back a Helm release to a specific revision?

helm history <release> lists revisions; helm rollback <release> <revision> restores one. The rollback itself creates a new revision, so the history is never rewritten — and --cleanup-on-fail removes resources created by the failed upgrade.

How do I get the values a deployed release was installed with?

helm get values <release> shows the user-supplied values; add --all to include chart defaults. Combined with helm get manifest, that reconstructs exactly what was deployed and why.

Helm Subcharts Explained: Values, Globals, import-values and Multiple Instances with Alias

Helm Subcharts Explained: Values, Globals, import-values and Multiple Instances with Alias

A Helm subchart is simply a chart that lives inside another chart. That one sentence hides most of the questions people actually have when they start using them: where the subchart comes from, how its values get set, what it can and cannot see from the parent, how to turn it off, and how to deploy the same subchart twice with different configuration.

This guide covers all of it, starting from the basics and ending with the pattern this post was originally written about: running multiple instances of the same subchart with alias. Everything here applies to charts with apiVersion: v2, which is what Helm 3 and Helm 4 use. For the wider picture of how charts, repositories and releases fit together, start with the complete Helm charts guide.

What Is a Helm Subchart?

A subchart is a complete, standalone chart that another chart (the parent, sometimes called an umbrella chart) includes and renders as part of the same release. When you run helm install on the parent, Helm renders the parent’s templates and every enabled subchart’s templates together, and all resulting objects belong to a single release with a single revision history.

There are two ways a chart can become a subchart.

Declared in Chart.yaml (the normal way)

You list it in the dependencies section of the parent’s Chart.yaml, and Helm downloads it into the charts/ directory for you:

# Chart.yaml
apiVersion: v2
name: shop
version: 1.4.0
dependencies:
  - name: postgresql
    version: "~16.2.0"
    repository: "https://charts.bitnami.com/bitnami"
  - name: redis
    version: "~20.1.0"
    repository: "oci://registry-1.docker.io/bitnamicharts"

Dropped manually into charts/

Any chart placed in the parent’s charts/ directory, either unpacked as a folder or as a .tgz archive, is treated as a dependency even if Chart.yaml does not mention it. Directory and file names starting with _ or . are ignored by the loader. This is handy for vendoring a chart you forked, but you lose version constraints and the lock file, so it is best reserved for special cases.

Subchart vs dependency: is there a difference?

In practice, no. “Dependency” is the declaration in Chart.yaml, “subchart” is the chart that ends up in charts/ and gets rendered. Every dependency becomes a subchart once it is fetched, and every subchart is a dependency of its parent. The distinction people usually mean when they search for “helm subchart vs dependency” is actually declared dependency vs manually vendored chart, which is the comparison above. If you want the full reference of every field in the dependencies block and how version ranges are resolved, see the Helm dependencies guide and the post on Helm version constraints.

helm dependency update, build and Chart.lock

Declaring a dependency does nothing on its own. You have to fetch it:

helm dependency update ./shop

update resolves each version range against the repository, downloads the matching archives into charts/, and writes Chart.lock with the exact versions and a digest of the dependency list. Run it whenever you change dependencies in Chart.yaml.

helm dependency build ./shop

build does the reproducible thing: it rebuilds charts/ from the versions pinned in Chart.lock without re-resolving ranges. This is what you want in CI.

Two errors you will see sooner or later:

  • found in Chart.yaml, but missing in charts/ directory means you declared a dependency and never fetched it. Run helm dependency build (or update).
  • the lock file (Chart.lock) is out of sync with the dependencies file (Chart.yaml) means someone edited Chart.yaml without updating the lock. Run helm dependency update and commit the new Chart.lock.

Commit Chart.lock. Whether to commit the charts/*.tgz files is a team choice: committing them makes builds hermetic, not committing them keeps the repository small and relies on helm dependency build in the pipeline.

Passing Values to a Subchart

This is the part that confuses people the most, and it follows one rule: values for a subchart go under a top-level key named after the subchart (or after its alias, as we will see later).

# shop/values.yaml
replicaCount: 3            # parent value

postgresql:                # everything under this key goes to the postgresql subchart
  auth:
    database: shop
    username: shop
  primary:
    persistence:
      size: 20Gi

redis:
  architecture: standalone

Inside the postgresql subchart, templates see .Values.auth.database, not .Values.postgresql.auth.database. Helm strips the prefix and hands the subchart only its own slice. Values you set in the parent under that key are merged on top of the subchart’s own values.yaml defaults, so you only need to write what you want to change.

The same works on the command line:

helm install shop ./shop \
  --set postgresql.primary.persistence.size=50Gi \
  --set redis.architecture=replication

To remove a default the subchart ships with, rather than overriding it, set it to null in the parent. The details and the edge cases of that are covered in Helm null values.

The parent can read subchart values, not the other way around

The parent’s templates can reference .Values.postgresql.auth.database because that key exists in the parent’s value tree. The subchart cannot reference replicaCount from the parent: it only receives its own section plus global. This is intentional. A subchart must render the same way whether it is installed alone or embedded, so it cannot depend on anything its parent happens to define.

Global Values

global is the one channel that reaches every chart in the tree:

# shop/values.yaml
global:
  imageRegistry: registry.example.com
  storageClass: fast-ssd

Every chart, parent and subcharts, can read .Values.global.imageRegistry. Globals flow downwards and parent globals take precedence over any global a subchart defines in its own values.yaml.

Two practical warnings. First, a global only does something if the subchart’s templates actually read it. Bitnami charts, for example, honour global.imageRegistry and global.storageClass because their templates were written to; a random chart may ignore both. Check the subchart’s templates or README before relying on a global. Second, do not use global as a general-purpose way to share configuration between your own charts. It works, but it creates an invisible contract between charts that is hard to trace months later. Explicit per-subchart values are easier to review.

Enabling and Disabling Subcharts: condition and tags

Every declared dependency is rendered by default. To make one optional you add a condition, a tags list, or both.

# Chart.yaml
dependencies:
  - name: postgresql
    version: "~16.2.0"
    repository: "https://charts.bitnami.com/bitnami"
    condition: postgresql.enabled
  - name: prometheus-exporter
    version: "1.x.x"
    repository: "https://example.com/charts"
    tags:
      - monitoring
  - name: grafana-dashboards
    version: "1.x.x"
    repository: "https://example.com/charts"
    tags:
      - monitoring
# values.yaml
postgresql:
  enabled: true

tags:
  monitoring: false

The resolution rules, from the Helm documentation:

  • A condition is a YAML path into the parent’s values that must resolve to a boolean. You can list several comma-separated paths; the first one that exists wins and the rest are ignored.
  • tags enable a chart if any of its tags is true.
  • When both are present and the condition path is set in values, the condition always overrides tags.
  • If neither resolves, the chart is enabled.

The usual pattern is a condition per subchart for fine control and a shared tag for groups that should switch on and off together, such as everything related to monitoring:

helm install shop ./shop --set tags.monitoring=true --set postgresql.enabled=false

A common mistake is to write condition: enabled instead of condition: postgresql.enabled. The path is resolved from the parent’s root, not from inside the subchart’s section.

import-values: Pulling Subchart Values Up to the Parent

Values normally flow down. import-values lets the parent pull specific values up from a subchart, which is useful when the subchart knows something the parent’s templates need, such as a port or a service name.

The exports format

The subchart publishes values under a top-level exports key:

# charts/api/values.yaml
exports:
  data:
    servicePort: 8080

The parent imports them by key:

# Chart.yaml (parent)
dependencies:
  - name: api
    version: "2.x.x"
    repository: "https://example.com/charts"
    import-values:
      - data

After rendering, the parent sees .Values.servicePort at its root. The contents of exports.data are merged into the parent’s root values, not under a data key.

The child-parent format

When the subchart does not export anything, you can map any path explicitly:

dependencies:
  - name: api
    version: "2.x.x"
    repository: "https://example.com/charts"
    import-values:
      - child: service
        parent: apiService

Now .Values.apiService.port in the parent holds whatever the subchart had in service.port. Imported values override defaults in the parent’s values.yaml, but values passed at install time with -f or --set still win.

Use import-values sparingly. If the parent needs a value that the subchart also needs, it is often clearer to define it once in the parent and pass it down, or put it in global.

What a Subchart Cannot Do

Knowing the limits saves a lot of debugging time:

  • It cannot see parent values. Only its own section and global. There is no .Parent.Values.
  • It cannot override parent values. Values flow down; import-values is the only way up, and the parent opts into it.
  • Named templates are global. Everything defined with {{ define }} across the parent and all subcharts shares one namespace. If two charts define mychart.labels, one silently wins. That is why well-written charts prefix every template with the chart name, and why helm create generates names like {{ include "shop.fullname" . }}.
  • It is part of the same release. You cannot upgrade, roll back or uninstall a subchart independently. A rollback of the parent rolls back every subchart.
  • Hooks run in the same release. Subchart hooks execute alongside the parent’s, ordered by weight, which can surprise you if two charts both ship a pre-install migration job. See the Helm hooks guide for ordering rules.

Multiple Instances of the Same Subchart with alias

Now to the less common but very useful case: you want the same subchart twice, each copy with its own configuration.

When you need it

The scenario that led me to this pattern was a set of microservices that belong to one application and share the same technology base. All of them could use the same generic chart (in my case a TIBCO BusinessWorks Container Edition chart; it could just as well be a generic go-microservice or spring-boot chart), but each one needs:

  • its own image,
  • its own configuration and environment variables,
  • its own endpoints, often talking to different databases or external systems.

Without subcharts you end up copying the chart once per service, and every fix to the base chart has to be applied N times. With one subchart instantiated N times, the base chart is versioned once and each service is just a block of values.

Other real cases: two Redis instances (one cache, one queue) with different persistence settings, or a primary and a reporting database from the same PostgreSQL chart.

Declaring the instances

Start from a parent that includes the generic chart once:

# Chart.yaml
apiVersion: v2
name: orders-platform
description: Orders application deployed from a shared service chart
version: 0.2.0
appVersion: "2.7.2"
dependencies:
  - name: service
    version: "~0.2.0"
    repository: "https://charts.example.com"

To have two instances, declare the same chart twice and give each an alias:

# Chart.yaml
apiVersion: v2
name: orders-platform
description: Orders application deployed from a shared service chart
version: 0.3.0
appVersion: "2.7.2"
dependencies:
  - name: service
    alias: order-api
    version: "~0.2.0"
    repository: "https://charts.example.com"
    condition: order-api.enabled
  - name: service
    alias: order-worker
    version: "~0.2.0"
    repository: "https://charts.example.com"
    condition: order-worker.enabled

name stays the same, so helm dependency update downloads the chart once. The alias is what makes each copy distinct: it becomes the values key for that instance and it becomes .Chart.Name inside the instance’s templates.

Configuring each instance

Each alias gets its own section in values.yaml:

# values.yaml
order-api:
  enabled: true
  image:
    repository: registry.example.com/orders/order-api
    tag: "2.5.2"
    pullPolicy: IfNotPresent
  service:
    port: 8080
  env:
    DB_HOST: orders-db.internal

order-worker:
  enabled: true
  replicaCount: 2
  image:
    repository: registry.example.com/orders/order-worker
    tag: "2.5.2"
  env:
    QUEUE_URL: amqp://queue.internal

Anything you do not set falls back to the shared chart’s defaults, so each block contains only what makes that service different.

Three things that bite with aliases

Use lowercase, DNS-safe aliases. Because the alias replaces .Chart.Name, the standard fullname helper builds resource names from it. An alias like serviceA (which the first version of this post used) produces names such as myrelease-serviceA, which Kubernetes rejects because object names must be lowercase. Stick to order-api style names.

Hyphenated aliases need index in parent templates. If the parent’s own templates need to read an instance’s values, .Values.order-api.service.port is a template parse error. Use {{ index .Values "order-api" "service" "port" }} instead. Templates inside the subchart are unaffected, because they see their own slice without the prefix.

The shared chart must use its chart name in resource names. If the base chart hard-codes name: service in its Deployment, both instances render an object with the same name and the install fails. A chart designed for reuse builds names from include "service.fullname", which derives from .Chart.Name and therefore from the alias.

Library Charts vs Subcharts

A library chart (type: library in Chart.yaml) is also declared as a dependency, but it renders nothing by itself. It only provides named templates the parent can include.

Subchart (application)Library chart
Renders Kubernetes objectsYesNo
Has its own values.yaml sectionYesNo, uses the caller’s context
Can be installed aloneYesNo
Typical useBundling a database, cache or serviceShared labels, Deployment skeletons, helpers
Multiple instancesWith aliasNot needed: call the template N times

For the multi-microservice case above there is a real alternative: a library chart that defines a full Deployment/Service template, and a parent that calls it once per service from a range over values. That keeps every service in the parent’s own templates and avoids alias quirks, at the cost of writing a bit more template code. Aliased subcharts are simpler when the shared chart already exists as an installable chart; library charts are cleaner when you design the abstraction from scratch.

helm upgrade with Subcharts

Upgrading a release that contains subcharts is the same helm upgrade, with two details worth knowing.

If you bump a dependency version, you must refresh charts/ before upgrading, either explicitly or with the flag:

helm dependency update ./shop
helm upgrade shop ./shop -f values-prod.yaml

# or in one step: fetches dependencies if they are missing
helm upgrade shop ./shop -f values-prod.yaml --dependency-update

Note that --dependency-update only fetches dependencies that are missing; it does not replace an older archive that is already sitting in charts/. After changing a version range, run helm dependency update explicitly.

Values behave as in any upgrade. Without --reuse-values, Helm starts from the chart’s defaults plus whatever you pass on this command; that is usually what you want, because a new subchart version may have renamed or added keys. --reuse-values merges your overrides on top of the previous release’s values and can hide new subchart defaults. --reset-then-reuse-values is the middle ground: chart defaults first, then the last release’s values, then your overrides. Before a subchart major version bump, render both versions with helm template and diff them. The advanced Helm commands post has more on inspecting a release before touching it.

Umbrella Charts: When to Use Them and When to Avoid Them

An umbrella chart is a parent whose main job is to bundle several subcharts into one deployable unit. Subcharts make them easy to build, which is exactly why they get overused.

They work well when:

  • the components share a lifecycle and are always released together, such as an application and its dedicated cache,
  • you want one versioned artifact per environment that says exactly what is deployed,
  • the same generic chart is instantiated several times, as in the alias example.

They hurt when:

  • components have different release cadences, because every change to one service creates a new revision of all of them and a rollback reverts everything,
  • different teams own different components and step on each other’s values,
  • the release grows so large that a single failed hook or a single invalid object blocks the whole upgrade.

If you are deciding how to organise charts across many services, the trade-offs between one central chart and per-service charts are covered in detail in Helm chart management: centralized vs per-service. Whatever structure you choose, test the charts with the subcharts enabled, and validate the values each instance receives with a values schema.

Frequently Asked Questions

What is a subchart in Helm?

A subchart is a complete Helm chart included inside another chart, either declared in the parent’s Chart.yaml under dependencies and fetched into charts/, or placed there manually. It is rendered as part of the parent’s release, receives its values from a key named after it in the parent’s values, and cannot see the parent’s own values except for global.

What is the difference between a Helm subchart and a dependency?

They are two views of the same thing. A dependency is the entry in Chart.yaml that says which chart and version to use; the subchart is that chart once it has been downloaded into charts/ and rendered. The only practical distinction is between declared dependencies, which have version ranges and a Chart.lock, and charts copied manually into charts/, which have neither.

How do I pass values to a Helm subchart?

Put them under a top-level key with the subchart’s name, or its alias if it has one, in the parent’s values.yaml, or use --set subchartname.key=value. Helm strips that prefix and merges the section over the subchart’s own defaults, so inside the subchart the values appear at .Values.key. Values that every chart should see go under global.

Can a subchart access the parent chart’s values?

No. A subchart only receives its own section of values and the global section. This keeps subcharts self-contained so they render identically whether installed alone or embedded. If a subchart needs something from the parent, pass it explicitly under the subchart’s key or place it in global.

How do I deploy the same Helm chart multiple times as subcharts?

Declare the same chart several times in dependencies with the same name and a different alias for each entry. Each alias becomes its own values key and its own .Chart.Name, so every instance gets separate configuration and separate resource names. Use lowercase, DNS-safe aliases, and make sure the shared chart builds resource names from its fullname helper rather than hard-coding them.

How do I disable a subchart?

Add condition: <subchart>.enabled to the dependency in Chart.yaml and set <subchart>.enabled: false in values or with --set. Alternatively assign tags and set tags.<tag>: false. If both are defined, a condition that resolves in values always overrides tags.

Do I need to run helm dependency update before helm upgrade?

Yes, whenever you change a dependency version in Chart.yaml. helm upgrade --dependency-update fetches dependencies that are missing from charts/, but it does not replace an archive that is already there, so after bumping a version run helm dependency update and commit the new Chart.lock. In CI, helm dependency build recreates charts/ from the lock file.

What is the difference between a library chart and a subchart?

A subchart is an application chart that renders its own Kubernetes objects and has its own values section. A library chart, marked with type: library, renders nothing and only provides named templates for the parent to include. Use subcharts to bundle components, and library charts to share template code across many charts.

Conclusion

Subcharts are Helm’s composition mechanism, and most of their behaviour follows from two ideas: values flow down under a key named after the subchart, and everything renders into one release. Once that is clear, global, condition, tags and import-values are just controlled exceptions to the flow.

The multi-instance pattern with alias builds on the same rules. It lets you keep one well-tested generic chart and instantiate it for every service that shares the same base, each with its own values, instead of maintaining a copy per service. Use it when the instances genuinely share a lifecycle, keep aliases lowercase, and reach for per-service releases or a library chart when they do not. That keeps the benefits of reuse without turning your umbrella chart into the monolith containers were supposed to get rid of.