Helm architecture

One Helm chart vs many application charts

We rendered six application shapes to compare a shared platform chart with charts owned by individual application teams. The results show where each model becomes expensive to operate.

Chart fidelity

The examples use Universal Helm Chart application 0.4.3, published on Artifact Hub. The image names and hostnames are examples; replace them before deploying.

Chart 0.4.3 — args / multi-port / service.enabled

From application 0.4.3: main container args (default []); Deployment revisionHistoryLimit (default 10); multi-port via containerPorts and service.ports; optional chart-managed Service via service.enabled (default true). When service.ports is set, Ingress / simple HTTPRoute / NOTES use the first entry (templates/_helpers.tpl application.servicePort). Legacy single-port service.name / service.port / service.protocol / service.appProtocol remain supported. Set service.enabled: false when the workload does not need a chart-managed Service. Do not invent other port keys.

Chart 0.4.2 — externalSecretHooks

Behavior change in application 0.4.2: ExternalSecret objects in extraManifests are ordinary resources by default. The chart does not add Helm hooks automatically. Recommend explicit lifecycle/ordering annotations on the manifest (for example argocd.argoproj.io/sync-wave: "-5"). Set externalSecretHooks.enabled: true (default false) only when legacy automatic helm.sh/hook: pre-install,pre-upgrade / hook-weight: "-5" / hook-delete-policy: before-hook-creation behavior is required (templates/1_extra-manifests.yaml). Do not invent other externalSecret* keys.

Versions used

What this comparison covers

The focus is template ownership: a shared chart versus one chart per application. CI image publishing, ApplicationSet generators, and repository promotion need a separate GitOps design. For a shorter introduction to the shared-chart model, read Why one Helm chart is enough.

Two models

Shared chart / platform API: one application chart owns Deployment, Service, optional Ingress/HTTPRoute, Job/CronJob, HPA, and ServiceMonitor. App teams ship values (and rare escape hatches). GitOps wrappers pin one chart version.

Chart-per-application: each service owns its templates/. Policy changes become N template MRs. Autonomy is high; drift and operating cost scale with service count.

The right choice depends on how similar the workloads are, how often policy changes, and how much independence an application team needs.

Inventory: six application shapes, one chart

The evidence pack renders six shapes through the same 0.4.3 package:

Each language profile under examples/one-chart-vs-many/ renders a Deployment, Service, and HTTPRoute. The shared examples set runAsNonRoot: true and related keys in values. The chart package leaves securityContext empty by default, so the platform baseline must supply it. Our small dedicated chart omits the block entirely. That is an easy mistake to repeat when teams copy a minimal template and then maintain it independently.

Rendered-manifest comparison (measured)

From package inventory of application 0.4.3:

A minimal dedicated FastAPI chart in examples/one-chart-vs-many/dedicated-contrast/ ships three templates (Deployment, Service, HTTPRoute) with no securityContext block. Scaling that pattern to six languages means six template trees to patch for every probe, label, or security change.

The battle test used one Universal Helm Chart dependency per wrapper across twelve Argo CD Applications: six apps in dev and prod. All twelve reported Synced/Healthy on the reference cluster. That earlier run used chart 0.3.5.

Shared values example (FastAPI, abbreviated)

# Abbreviated FastAPI values
replicaCount: 2
image: ghcr.io/example-org/fastapi
imageTag: "1.0.0"
imagePullPolicy: IfNotPresent
service:
  port: 8080
securityContext:
  runAsNonRoot: true
  runAsUser: 10001
  runAsGroup: 10001
  allowPrivilegeEscalation: false
  readOnlyRootFilesystem: true
  capabilities:
    drop: ["ALL"]
route:
  enabled: true
  gateway: external
  gatewayNamespace: kgateway-system
  sectionName: https-wildcard
  hostname: battle-fastapi-prod.example.com

Full shape files also set probes, resources, and envSecrets: examples/one-chart-vs-many/shapes/*/values.yaml.

GitOps wrapper (chart pin, not template fork)

# environments/prod/demo-api/Chart.yaml
dependencies:
  - name: application
    version: "0.4.3"
    repository: https://chaser100.github.io/u-helm-chart

# environments/prod/demo-api/values.yaml
application:
  image: ghcr.io/example-org/fastapi
  imageTag: "1.0.0"
  route:
    enabled: true
    gateway: external
    gatewayNamespace: kgateway-system
    sectionName: https-wildcard
    hostname: battle-fastapi-prod.example.com

CI updates imageTag, while the platform team reviews chart version bumps. Application teams control what they deploy; the platform team controls the shared Kubernetes contract.

Upgrade and policy rollout

  1. Platform ships a new application chart version with template/policy changes.
  2. Bump the wrapper dependency once (canary app/env first).
  3. Argo CD syncs; verify Deployment rollout and HTTPRoute parent Accepted=True / ResolvedRefs=True.
  4. Roll the pin across ApplicationSet paths.

Rollback: revert the wrapper Chart.yaml dependency to the previous chart version, sync Argo CD, and confirm that Kubernetes restores the prior ReplicaSet. Track the versions used by every rollout ring so a partial upgrade does not become a permanent mixed fleet.

With one chart per application, the same policy change requires a merge request for every template tree. The longer rollout gives those charts more time to diverge.

Exceptions and escape hatches

The chart has first-class keys for conventional applications. Vendor-specific objects such as a kgateway TrafficPolicy should not masquerade as portable route.* fields. Version 0.4.3 has no route.cors or route.rateLimit key. Add the policy through extraManifests and label it clearly:

extraManifests:
  - |
    apiVersion: gateway.kgateway.dev/v1alpha1
    kind: TrafficPolicy
    metadata:
      name: demo-api-cors
      labels:
        networking.contract/kind: kgateway-specific
    spec:
      targetRefs:
        - group: gateway.networking.k8s.io
          kind: HTTPRoute
          name: demo-api
      cors:
        allowOrigins:
          - https://app.example.com

If most of the release lives in escape hatches, use a dedicated chart. A shared chart should remain an application contract, not grow into a second Kubernetes configuration language.

Multi-workload applications can also make a values file unwieldy. In an assessment made against chart 0.3.7, one application needed roughly 423 lines of application-specific values compared with about 630 lines of Universal values. Much of the extra text repeated security contexts and selectors rather than business configuration. At that point, improve the shared API or move the workload to a dedicated chart.

Failure modes

Decision matrix (summary)

Prefer the shared chart when the shape is Deployment/Service/(Ingress|HTTPRoute) plus optional Job/CronJob/HPA/ServiceMonitor and exceptions are rare and labeled.

Prefer a dedicated chart when any of these hold:

Official documentation

Why one Helm chart is enough → · Gateway API examples → · Browse language examples →