Skip to Content
Migration guide

Ported from docs/migration/v1-to-v2.md in the repository. A Helm 3 → Helm 4 / server-side-apply migration section is a separate future write-up, not covered here.

Migration guide — platform library v1 → v2

This is a task-oriented upgrade path for a consumer chart moving from the v1 platform-library to the v2 platform library. v2 is a pure library chart with a single render entrypoint and capability-negotiated API versions.

What broke, and why

  1. The library no longer self-renders. v1 shipped roughly 25 non-underscore wrapper templates (deployment.yaml, service.yaml, app.yaml, and so on) that emitted objects on their own. These were deleted. The chart is now purely type: library, so helm install platform-library ... correctly fails — a library chart cannot be installed on its own. Every template is now _-prefixed.

  2. Consumers must render explicitly. Instead of relying on the library to self-render, your chart calls the single public entrypoint from its own templates/app.yaml:

    {{ include "platform.render" . }}

    platform.render composes the opinionated tier-1 objects, the tier-2 extraObjects, and the raw extraManifests.

  3. The chart was renamed and re-versioned. The dependency name is now platform (source directory platform-library/), and it targets kubeVersion: ">=1.34.0-0 <1.37.0-0" (an n-2 support window that moves forward with each Kubernetes release).

  4. API versions are now negotiated, not hard-coded. HPA, Ingress, PDB, CronJob, Certificate, mTLS, Gateway API, ServiceMonitor, and PodMonitor all pick the best apiVersion the target cluster serves. CRD-backed objects skip themselves when their API is absent, instead of rendering a manifest that would fail at apply time.

  5. New values keys: capabilities.apiVersions, extraObjects, extraManifests, plus everything documented on the values reference page that shipped after v2’s initial release (generatedSecrets, webhooks, tlsSelfSigned.mtls, rbac, resourceQuota, limitRange, prometheusRule, verticalAutoscaling, and more) — none of these existed in v1 at all.

Fast path: scaffold a fresh consumer chart

If you’d rather start clean than hand-edit an existing chart, generate a pre-wired consumer and move your overrides into it:

scripts/new-app-chart.sh my-service # options: --dir <path> --repo <url, default file://../platform-library> # --version <range, default ">=2.0.0-0"> --app-version <v>

This emits a Chart.yaml with the platform dependency (including import-values: [defaults]), a templates/app.yaml reading {{ include "platform.render" . }}, an overrides-only values.yaml, a .helmignore, and a values.schema.json copied from the library’s reference schema so your root values are validated on every helm invocation. The manual steps below describe the same end state if you’d rather upgrade an existing chart in place.

Step 1 — update Chart.yaml

apiVersion: v2 name: my-service version: 1.0.0 dependencies: - name: platform version: ">=2.0.0-0" # OCI registry in normal use: repository: oci://ghcr.io/caretak3r/charts # ...or a local path for development: # repository: file://../platform-library import-values: [defaults] # MANDATORY — see the footgun below

Step 2 — add the render entrypoint

Create (or replace) templates/app.yaml with exactly one line:

{{ include "platform.render" . }}

Delete any per-object wrapper templates you were carrying to work around v1 — they’re unnecessary now and will produce duplicate output alongside platform.render.

Step 3 — keep your tier-1 overrides as-is

The opinionated tier-1 blocks are unchanged in shape (service, ingress, autoscaling, certificate, mtls, gatewayApi, serviceMonitor, and so on). Your existing overrides continue to apply. No hard-coded apiVersion is needed for any of these anymore — negotiation handles it.

The import-values: [defaults] footgun. This is the single most common integration failure. The library ships all its defaults under exports.defaults. Without import-values: [defaults] on the dependency, those defaults never reach your root scope and every value renders empty — you get either a near-empty render or nil template errors, with no obvious cause pointing back at the missing line.

dependencies: - name: platform version: ">=2.0.0-0" repository: oci://ghcr.io/caretak3r/charts import-values: [defaults] # <-- without this, all values are empty

Step 4 — move custom resources into extraObjects

Anything you previously dropped as raw YAML in your own chart — RBAC, quotas, priority classes, and similar objects — should move into the tier-2 extraObjects map so it gets standard labels, namespace stamping, and capability negotiation for free.

extraObjects is a map of Kind -> list of specs. Reserved keys per spec are name, namespace, labels, annotations, apiVersion, kind, clusterScoped; every other top-level key passes through verbatim.

extraObjects: Role: - name: app-reader rules: - apiGroups: [""] resources: ["configmaps", "secrets"] verbs: ["get", "list", "watch"] RoleBinding: - name: app-reader-binding roleRef: apiGroup: rbac.authorization.k8s.io kind: Role name: app-reader subjects: - kind: ServiceAccount name: my-service PriorityClass: # cluster-scoped: no namespace is stamped - name: my-high value: 1000000 globalDefault: false description: "High priority"
  • Built-in Kinds (RBAC, quotas, priority classes, and so on) always render at their best available version.
  • CRD Kinds placed here skip when their API is absent.
  • metadata.name is required; a missing name fails with extraObjects.<Kind>[].name is required.
  • Set clusterScoped: true on a spec to force-suppress the namespace for a Kind the library doesn’t already know is cluster-scoped.

If you were relying on RBAC in extraObjects, note that v2 later grew a dedicated first-class rbac block for namespaced Role/RoleBinding generation tied to the chart’s own ServiceAccount — see Values reference. It’s simpler for the common case; extraObjects.Role/RoleBinding still works for anything more bespoke.

Step 5 — use extraManifests only for the truly bespoke

For anything the library doesn’t model and that you don’t want normalized, use the raw escape hatch. It’s a list; you supply the full manifest. String entries run through tpl (so they can contain template expressions); map entries render verbatim. No labels, namespace, or negotiation are added.

extraManifests: - apiVersion: v1 kind: ConfigMap metadata: name: raw-config data: raw: "true" # A template string is also allowed: - | apiVersion: v1 kind: ConfigMap metadata: name: {{ .Release.Name }}-templated data: ns: {{ .Release.Namespace }}

Step 6 — rendering without a cluster (CI / helm template)

Under bare helm template (no cluster), Helm’s API discovery is minimal and reports no CRDs. Built-in objects still render (they negotiate-or-fall back to a default), but CRD-backed objects — Certificate, mTLS, Gateway routes, ServiceMonitor, PodMonitor, PrometheusRule, and any CRD in extraObjects — will skip unless you force-assume their groups:

capabilities: apiVersions: - gateway.networking.k8s.io/v1 - cert-manager.io/v1 - security.istio.io/v1beta1 - monitoring.coreos.com/v1

Entries may be group/version or the fuller group/version/Kind. On a real cluster that serves these APIs you don’t need the override — it’s for local development and CI rendering only.

Render locally, directly or through the test harness:

helm dependency update . helm template my-service . --kube-version 1.34

Upgrade churn and immutability warnings

  • Objects appear and disappear with CRDs. Because CRD-backed objects skip when their API is absent, installing or removing a CRD (cert-manager, for example) changes what renders. On the next helm upgrade those objects will be created or pruned accordingly. This is expected — plan CRD install and removal around your release windows.
  • beta → GA can force a replace. If negotiation moves an existing object from a beta to a GA apiVersion (say policy/v1beta1policy/v1), Helm or the API server may need to replace rather than update the resource. Expect brief churn for those specific objects on the first v2 upgrade.
  • Immutable fields. Never override immutable fields on live objects — most importantly spec.selector. As with v1, avoid putting mutable labels into a Service or workload selector: selectors are immutable, and changing one breaks matching against existing pods (a delete/recreate is then required).

Quick checklist

  • Dependency renamed to platform, version: ">=2.0.0-0".
  • import-values: [defaults] present on the dependency.
  • templates/app.yaml is exactly {{ include "platform.render" . }}.
  • Old per-object wrapper templates removed.
  • Custom resources moved to extraObjects (or extraManifests for the truly bespoke).
  • capabilities.apiVersions set for any CI/local render that needs CRD-backed objects.
  • Verified helm dependency update && helm template renders as expected.
Last updated on