Skip to content
Hogin Hogin
Go back

Helm, grown up: OCI registries, dependencies, and charts without the pain

10 мин чтения

Open a random Helm chart and you’ll almost always find a values.yaml without a single comment, a template that fails with a cryptic error on an empty field, and a charts/ directory full of .tgz files nobody remembers how to update. What actually helps isn’t “yet another linter” — it’s a handful of concrete practices: a schema on values, charts with explicit dependencies, and shipping through the same OCI registry that already holds your images. Here’s what that looks like in practice.

Table of contents

Open Table of contents

Why the classic chart repository is aging out

Helm originally distributed charts the way apt or yum distribute packages: helm repo add registers a URL with an index.yaml file listing every version of every chart in the repository, and helm install fetches the right .tgz from a link in that index. The model works, but it has two chronic pains. First, index.yaml has to be regenerated and republished on every release — a separate step that’s either automated in CI or forgotten. Second, the chart repository lives apart from the image registry: different auth, different RBAC, a different set of vulnerability-scanning tools.

As of Helm 3.8, OCI registry support is stable, and a chart can be published as an ordinary OCI artifact in the same registry that already holds the application’s images. In that model there’s no index.yaml at all — the version becomes the artifact’s tag, and helm push/helm pull talk directly to an oci:// address. But changing the transport doesn’t fix the chart itself: if values.yaml has no validation for required fields and dependencies are .tgz files downloaded by hand, moving to OCI just ships the same problems faster.

Left: a typical chart with problems in values and dependencies. Right: the same files with a values.schema.json and dependencies contract

Where a chart usually breaks

Chart anatomy is simple: Chart.yaml with metadata and a dependencies list, values.yaml with the defaults, templates/ with the manifests, and a templates/_helpers.tpl holding named templates (define/include) for repeated bits like labels. The structure isn’t the problem — it’s identical between a careful chart and a sloppy one. The problem is that values.yaml is usually nothing more than a convention: nothing stops you from passing replicas: "three" or forgetting a required image.repository, and the template either silently renders a broken manifest or fails with a Go-template error that gives no clue which field is at fault.

The fix isn’t “write more carefully” — it’s turning values.yaml into a checkable contract. A values.schema.json next to values.yaml describes field types and required-ness with JSON Schema, and helm install/helm template reject values that don’t validate, before any template even renders:

{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "type": "object",
  "required": ["image"],
  "properties": {
    "image": {
      "type": "object",
      "required": ["repository", "tag"],
      "properties": {
        "repository": { "type": "string" },
        "tag": { "type": "string" }
      }
    },
    "replicas": { "type": "integer", "minimum": 1 }
  }
}

Inside the templates themselves the same idea works through required and fail: required stops the render with a clear message if a field is missing, and fail stops it if a combination of fields is invalid on its own (say, ingress.enabled: true without ingress.host):

image: "{{ required "set .Values.image.repository" .Values.image.repository }}:{{ .Values.image.tag }}"
{{- if and .Values.ingress.enabled (not .Values.ingress.host) }}
{{ fail "ingress.enabled=true requires ingress.host" }}
{{- end }}

The practical difference is real: without a schema, a typo in values reaches kubectl apply and fails somewhere inside a controller; with a schema, it fails at helm install naming the exact field.

Dependencies: subcharts, alias, and condition instead of copy-paste

The second recurring pain point is dependencies. The classic way to reuse logic between charts is to copy a template from a neighboring one. Chart.yaml can describe dependencies declaratively instead:

dependencies:
  - name: postgresql
    version: "15.x.x"
    repository: "oci://registry-1.docker.io/bitnamicharts"
    condition: postgresql.enabled
  - name: common
    version: "2.x.x"
    repository: "oci://registry.internal/charts"
    alias: common

condition ties a dependency’s inclusion to a boolean field in the parent chart’s values — so one chart can optionally pull in a bundled database for a dev environment and skip it in production, where the database is external. alias lets you attach the same chart twice under different names (say, two redis instances — one for caching, one as a queue), each getting its own block in values under the alias name. After editing Chart.yaml, helm dependency update fetches the right .tgz files into charts/ — nothing needs to be dropped in there by hand.

A separate kind of dependency is a library chart: a Chart.yaml with type: library renders no manifests on its own and can’t be installed directly — it only declares named templates in its _helpers.tpl. The point is to move shared labels, annotations, or a common securityContext block into one place and pull them into several application charts via dependencies, instead of copying _helpers.tpl between repositories.

Library chart common is pulled in as a dependency by checkout-app; the resulting chart is published to OCI and installed into the cluster

What it takes to build and publish the pair to OCI

Take that exact case: a library chart common with shared helpers, and an application chart checkout-app that uses it. In common/templates/_helpers.tpl:

{{- define "common.labels" -}}
app.kubernetes.io/name: {{ .Chart.Name }}
app.kubernetes.io/managed-by: Helm
{{- end -}}

In checkout-app/Chart.yaml we add the dependency and wire its template into the manifests:

apiVersion: v2
name: checkout-app
version: 1.4.0
dependencies:
  - name: common
    version: "1.0.0"
    repository: "oci://registry.internal/charts"
# checkout-app/templates/deployment.yaml
metadata:
  labels:
    {{- include "common.labels" . | nindent 4 }}

Next comes building and publishing. Package the chart, optionally sign the provenance file with a GPG key, log in to the registry, and push it as an OCI artifact:

helm dependency update checkout-app/
helm package checkout-app/ --sign --key "release@internal" --keyring ~/.gnupg/secring.gpg
helm registry login registry.internal -u "$REGISTRY_USER" -p "$REGISTRY_PASS"
helm push checkout-app-1.4.0.tgz oci://registry.internal/charts

Installing from OCI looks like installing from a classic repository, except instead of helm repo add plus a repo name you use a direct oci:// address with an explicit version — OCI charts have no helm search repo, so you need to know the version up front, which is exactly the part of a GitOps pipeline where Flux or Argo CD just reference a specific tag:

helm pull oci://registry.internal/charts/checkout-app --version 1.4.0
helm install checkout oci://registry.internal/charts/checkout-app --version 1.4.0 -f values-prod.yaml

Lint and test before pushing to the registry

A values schema catches typos in data, but not a broken Go template or invalid output YAML. Three levels of checking are worth running in CI before helm push:

For charts that live in one monorepo and change in batches, chart-testing (ct) adds a level above that: ct lint --config ct.yaml uses git diff to find only the charts that changed and lints those, and ct install deploys each one into an ephemeral kind cluster and checks that helm install and helm test succeed — the thing you’d typically run in GitHub Actions before merging a PR that touches a chart, not before every manual helm push.

Helm or Kustomize: not interchangeable

The question of whether to drop Helm altogether and keep only Kustomize — it ships built into kubectl and needs no templating engine — comes up periodically. The two tools solve different problems:

HelmKustomize
ModelTemplating + a versioned package with valuesPatches over already-valid manifests
ParameterizationFull values, a schema, required/failNo template language — only overlays/patches
Cross-package dependenciesdependencies, alias, condition, library chartsNo dependency concept — just resources:
DistributionOCI registry or classic repo, semverUsually just a directory in a git repo
Release lifecyclehelm upgrade/rollback, hooks (pre-install, etc.)No release history — kubectl just applies
Where it shinesA reusable package for many consumersSmall per-environment differences within one team

The practical rule: if the chart is published outward — for another team, for customers, as part of a product — Helm’s versioning and values contract pay for themselves. If the whole point is that dev/staging/prod share the same YAML with a few pointed differences (a different namespace, a different replica count), a Kustomize overlay is shorter and doesn’t force you to maintain a twenty-field values.yaml for three real differences. Mixing both — rendering with helm template and then applying a Kustomize patch on top — is a workable transitional pattern, but as a permanent setup it multiplies the places the render can break.

How to verify the chart is actually ready

Before calling a chart “ready for GitOps,” run the whole chain by hand once. helm lint chart/ should return 0 chart(s) failed. helm template chart/ --values values-prod.yaml --validate (the --validate flag runs validation through the Kubernetes API, not just a render) should fail neither on missing required fields nor on schema errors. After helm push, helm pull oci://registry.internal/charts/checkout-app --version 1.4.0 --prov should successfully fetch both the chart and its provenance file, and helm verify checkout-app-1.4.0.tgz should confirm the signature against the same keyring used for helm package --sign. One last check that’s easy to skip: helm install --dry-run --debug with an empty values.yaml (no overrides at all) should fail with a clear message from required, not a Go-template stack trace — that’s the proof the values contract actually works, rather than just existing in documentation.

Bottom line

A good Helm chart isn’t one without a single {{ if }} — it’s one where values.yaml is guarded by a schema and required/fail, dependencies are declared through condition and alias instead of copy-pasted templates, and delivery goes through an OCI registry with semver and provenance sitting next to the application’s own images, rather than through a separate index.yaml someone forgot to update. None of these steps require rewriting an existing chart from scratch — they can be added one at a time, starting with the values schema, which catches the most surprises for the least effort.


Share this post:

Next Post
KRO: composite Kubernetes APIs without writing your own operator