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 butvalues.yamland a couple of one-line templates thatincludethe 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.tpledit, 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
| Concern | Centralized repo | Per-service charts | Library + thin charts |
|---|---|---|---|
| Who owns a service’s chart | Platform team | Service team | Service team (values), platform team (templates) |
| Standard changes across 200 services | One PR, 200 blast radius | 200 PRs | One library release + automated version bumps |
| Chart lives with the code | No | Yes | Yes |
| Quality gates | Central CI | Per-repo, varies | Central CI on the library, template CI on services |
| Drift between services | Low | High | Low |
| Platform team as bottleneck | Yes | No | No |
| Skill required from service teams | Low | High | Low |
| Breaking change containment | Poor | Good | Good (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.ioInside 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/chartsNote 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 --strictandhelm unittestagainst 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 templateand validate with kubeconform against every Kubernetes minor you run. - Ship a
values.schema.jsonfor the values the library expects consumers to provide, so a typo in a service’svalues.yamlfails athelm installrather than at runtime; the pattern and its limits are in Helm values schema validation. CODEOWNERSon 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: 5Argo 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.
- Extract the library first. Pull the shared
_helpers.tpland the most common Deployment shape intocommon0.1.0 and publish it to the registry. Nothing consumes it yet. - 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.
- Cut
common1.0.0 once the converted charts render identically to what is deployed.helm templatediffs against the live release are the acceptance test. - Move charts to their service repos one team at a time, in the order of who asks first. The chart is now three
includelines and avalues.yaml, so the move is small and the team is getting something (ownership) rather than being handed a chore. - Turn on Renovate for
commonacross 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.