Deploy Helm Charts with Pulumi Kubernetes Python
Most cluster add-ons ship as Helm charts, and rewriting them as Python resources is wasted effort. This guide, part of managing Kubernetes with the Pulumi Python provider under Pulumi patterns and provider management, installs a chart with typed values and explains why Chart and Release behave so differently.
Context
The provider offers two ways to install a chart, and picking the wrong one causes lasting confusion. Release runs Helm proper: Tiller-free but still a Helm release, with its own release secret in the target cluster and helm rollback available. Chart renders the chart's templates locally and submits the resulting objects as ordinary Pulumi resources — no release secret, but every rendered object appears individually in pulumi preview.
The distinction matters for diffing. With Chart you see exactly which Deployment changed; with Release you see one opaque resource whose values changed. Teams that care about reviewable infrastructure diffs generally prefer Chart.
That difference runs deeper than presentation. Release embeds the Helm SDK and performs a genuine install, which means the target namespace ends up holding a release secret of type helm.sh/release.v1 named sh.helm.release.v1.<release>.v1. Helm owns the rollback history stored there, helm list -n ingress sees the release, and Helm's hook machinery — pre-install, post-upgrade, and the weights that order them — runs exactly as it would from the CLI. The Pulumi state, meanwhile, records a single resource whose inputs are the chart coordinates and the values tree.
Chart does none of that. It shells the chart through helm template semantics locally, parses the resulting YAML documents, and registers each one as a first-class Pulumi resource with its own URN. There is no release secret, helm list shows nothing, and objects annotated as Helm hooks are submitted as ordinary resources because there is no Helm client in the loop to interpret them. In exchange you get a preview that names every ConfigMap, ServiceAccount and Deployment the chart would create, and a pulumi up that can be reviewed the same way any other change is reviewed.
There is a version dimension too. pulumi-kubernetes 4.x ships both helm.v3.Chart, which renders in the Pulumi process, and helm.v4.Chart, which delegates rendering to the provider and models the result as a component with a single resources output. The v3 form is what most existing programs use and what this guide shows; v4 changes the URN layout, so switching an installed chart between them is a replacement, not an upgrade.
Prerequisites
pulumi-kubernetes>=4.0and a configuredk8s.Provider, typically built from a kubeconfig the stack itself produced- A local
helmbinary onPATH—helm.v3.Chartrenders through it, so its absence fails the preview withfailed to pull chart: exec: "helm": executable file not found in $PATH - The chart's repository URL and a pinned chart version — never an unpinned latest
- RBAC sufficient for everything the chart creates, which for add-ons is often cluster-scoped
- The target namespace already created, or
create_namespace=Trueon aRelease;Chartdoes not create it and every object then fails withnamespaces "ingress" not found
# CLI: inspect the chart's values before templating it into Python
helm show values ingress-nginx/ingress-nginx --version 4.10.1 | head -40
Reading the values file first is not optional busywork. Chart authors nest values arbitrarily deep and rename keys between major chart versions, and because unknown keys are ignored rather than rejected, a value set at the wrong path produces a successful deploy with none of the intended effect.
Implementation
Step 1 — model the values as a typed object. A dataclass turns the chart's untyped value tree into something mypy can check and a reader can review.
# values.py — typed subset of the chart values this stack actually sets
# CLI: python -m mypy values.py --strict
from dataclasses import dataclass, asdict
from typing import Any
@dataclass(frozen=True)
class IngressValues:
replica_count: int = 2
service_type: str = "LoadBalancer"
metrics_enabled: bool = True
def to_helm(self) -> dict[str, Any]:
return {
"controller": {
"replicaCount": self.replica_count,
"service": {"type": self.service_type},
"metrics": {"enabled": self.metrics_enabled},
}
}
The dataclass is doing two jobs. It gives every value a name and a type that mypy --strict will check at the call site, and — more importantly — it confines the chart's camelCase key paths to a single to_helm method. When the chart's next major version moves controller.metrics.enabled somewhere else, one method changes rather than every stack that installs the chart. Keep the dataclass to the values you actually override; mirroring the whole values.yaml recreates the problem you are trying to solve.
Step 2 — install the chart with a pinned version. Pinning both the chart version and the repository makes the deploy reproducible.
# ingress.py — install the chart as individually tracked resources
# CLI: pulumi up
import pulumi
import pulumi_kubernetes as k8s
from values import IngressValues
ingress = k8s.helm.v3.Chart(
"ingress-nginx",
k8s.helm.v3.ChartOpts(
chart="ingress-nginx",
version="4.10.1", # pinned: an unpinned chart is a rolling deploy
namespace="ingress",
fetch_opts=k8s.helm.v3.FetchOpts(
repo="https://kubernetes.github.io/ingress-nginx"),
values=IngressValues().to_helm(),
),
opts=pulumi.ResourceOptions(provider=k8s_provider),
)
# State implication: every rendered object is a separate resource in state,
# so a chart upgrade shows a per-object diff rather than one opaque change.
The provider= option is worth being deliberate about. Without it the resource uses the ambient provider derived from the caller's KUBECONFIG, which on a developer laptop is whatever context happens to be current — a reliable way to install an ingress controller into the wrong environment. Pass an explicit k8s.Provider built from the kubeconfig the infrastructure stack produced, and the target is a property of the program rather than of the machine running it.
Step 3 — patch rendered objects with a transformation. Charts rarely expose every field you need. A transformation intercepts each rendered object before it is registered, so you can set what the chart's values do not reach without forking the chart.
# transforms.py — add a topology constraint the chart does not expose
# CLI: pulumi preview --diff
from typing import Any, Optional
import pulumi
def spread_controller_pods(
obj: dict[str, Any], opts: pulumi.ResourceOptions
) -> Optional[dict[str, Any]]:
"""Mutate the rendered Deployment; every other object passes through."""
if obj.get("kind") != "Deployment":
return None
if obj["metadata"]["name"] != "ingress-nginx-controller":
return None
spec = obj["spec"]["template"]["spec"]
spec["topologySpreadConstraints"] = [{
"maxSkew": 1,
"topologyKey": "topology.kubernetes.io/zone",
"whenUnsatisfiable": "DoNotSchedule",
"labelSelector": {"matchLabels": {
"app.kubernetes.io/name": "ingress-nginx"}},
}]
return obj
# Provider note: the transformation runs during rendering, before the
# object is registered, so the change appears in `pulumi preview --diff`
# exactly as if the chart had emitted it.
Register it on the chart options — k8s.helm.v3.ChartOpts(..., transformations=[spread_controller_pods]) — and keep each transformation narrow. Returning None leaves an object untouched, so a guard clause per transformation is cheaper to reason about than one function with a chain of conditionals. Because transformations mutate a plain dictionary, they are also trivially unit-testable without a Kubernetes API server: build the input dict, call the function, assert on the result.
Verification
Confirm the chart's workloads are running at the version you pinned, and that the values took effect.
# CLI: the controller is ready and reporting the pinned chart version
kubectl -n ingress rollout status deployment/ingress-nginx-controller
kubectl -n ingress get deployment ingress-nginx-controller \
-o jsonpath='{.metadata.labels.helm\.sh/chart}{"\n"}'
kubectl -n ingress get svc ingress-nginx-controller -o jsonpath='{.spec.type}{"\n"}'
The chart label should contain 4.10.1 and the service type should match the value you set. If you installed with Release instead, helm -n ingress list -o json is also authoritative; with Chart it returns an empty array, which is correct rather than a symptom.
The complementary check runs on the Pulumi side, and answers a question kubectl cannot: does the stack still own every object the chart produced? Listing the URNs makes accidental out-of-band edits obvious, because an object someone patched by hand shows as a diff on the next preview rather than staying silently divergent.
# CLI: every rendered object the stack tracks, one URN per line
pulumi stack --show-urns | grep 'ingress-nginx'
pulumi preview --diff --stack prod # must be empty on an unchanged stack
For a scripted gate — a smoke test in the pipeline, say — read the values back out of the running object rather than trusting the apply exit code:
# check_ingress.py — assert the deployed replica count matches the stack's intent
# CLI: python check_ingress.py --namespace ingress
import argparse
from kubernetes import client, config
def deployed_replicas(namespace: str, name: str) -> int:
config.load_kube_config()
apps = client.AppsV1Api()
dep = apps.read_namespaced_deployment(name=name, namespace=namespace)
# State implication: this reads live cluster state, not Pulumi state — a
# mismatch means someone scaled the Deployment outside the stack.
return dep.status.ready_replicas or 0
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--namespace", default="ingress")
args = parser.parse_args()
ready: int = deployed_replicas(args.namespace, "ingress-nginx-controller")
assert ready == 2, f"expected 2 ready replicas, found {ready}"
Gotchas & Edge Cases
CRDs in charts are not upgraded. Helm installs a chart's CRDs on first install and never updates them. A chart upgrade that expects a newer CRD schema fails at apply; manage the CRDs explicitly as described in managing Kubernetes CRDs.
Chart renders with the local Helm version. Template functions added in a newer Helm can fail to render on an older local binary, producing errors that look like chart bugs. Pin the toolchain alongside the chart version.
Value keys are camelCase. Chart values follow the chart author's schema, not Python conventions. A misspelled key is silently ignored rather than rejected — which is exactly why routing them through a typed dataclass is worth the effort.
Helm hooks are inert under Chart. An object annotated helm.sh/hook: post-install is submitted as a normal resource, at whatever point the dependency graph reaches it, with no ordering weight and no hook-delete-policy honoured. For charts whose migration Jobs are hooks, that is a correctness problem, not a cosmetic one — either use Release for that chart or lift the Job out and order it explicitly with depends_on.
Await timeouts read like chart bugs. The provider blocks until each object reports ready, so a controller that cannot schedule fails after several minutes with the Kubernetes API server reported that "ingress/ingress-nginx-controller" failed to become live: 'ingress-nginx-controller' timed out waiting to be Ready. The chart is usually fine; the cause is nodes without capacity, a missing image pull secret, or an unsatisfiable topology constraint. Check kubectl -n ingress describe pod before touching the chart version. The await mechanism itself is covered in awaiting Kubernetes rollouts in Pulumi Python.
Adopting an existing Helm install. Pointing a Chart at a namespace where the same chart was installed by the Helm CLI fails per object with resource "ingress/ingress-nginx-controller" was not successfully created by the Kubernetes API server: services "ingress-nginx-controller" already exists. Either helm uninstall first and accept the gap, or import each object with pulumi import before the first up. There is no flag that makes Pulumi silently take ownership.
A Release name collides with its own leftovers. A failed Release install leaves the release record behind, and the retry fails with cannot re-use a name that is still in use. helm -n ingress uninstall <name> clears it; the equivalent situation under Chart cannot arise because there is no release record.
Operational Notes
Chart upgrades are the recurring work, and the reason to pin versions is that it makes them a deliberate, reviewable event rather than something that happens when a repository index refreshes.
The loop that holds up in practice is: watch the chart's release notes, bump the pinned version in a branch, run pulumi preview --diff against a staging stack, read the per-object diff, apply, then promote the same version string to production. The middle step is the one Chart buys you — a chart bump that quietly changes a securityContext or drops a Service port shows up as a concrete field diff before it reaches any environment. Keep the values dataclass and the version string in the same module so a reviewer sees both in one hunk.
Two upgrade classes deserve extra care. CRD changes are invisible to Helm, so read the chart's upgrade notes for CRD instructions and apply them separately. Changes to immutable fields — a Deployment's spec.selector, a Service's clusterIP, a StatefulSet's volumeClaimTemplates — cannot be updated in place; the provider will plan a replacement, and for a StatefulSet that means data movement. pulumi preview --diff marks these as replace rather than update, which is the signal to plan a maintenance window instead of merging on a Friday.
Drift is the other standing concern. Anything that mutates chart-owned objects from outside — an operator, a kubectl scale, an admission webhook injecting sidecars — shows up on the next preview as a diff the stack wants to revert. For fields another controller legitimately owns, ignore_changes on the specific path is the honest fix:
# ingress.py (continued): let the autoscaler own the replica count
# CLI: pulumi preview --stack prod
opts = pulumi.ResourceOptions(
provider=k8s_provider,
ignore_changes=["spec.replicas"],
)
# State implication: the field stays in state at its last-applied value but is
# never diffed again, so an HPA can scale the Deployment without the next
# `pulumi up` scaling it back down.
When several stacks install the same chart with the same conventions, wrap the chart, its values dataclass and its transformations in a component resource rather than copying the block. That is the packaging boundary described in Pulumi component resources, and it gives you one place to bump the pinned version for every consumer.
FAQ
Chart or Release for a new install?
Chart, unless you specifically need Helm's release lifecycle. Per-object diffs make upgrades reviewable, which is the main reason to manage add-ons in code at all.
Can I patch one object a chart renders?
Yes — with Chart, use a transformation function to modify the rendered object before it is submitted. That is far safer than forking the chart.
Does pinning the chart version pin the images?
No. Most charts default their image tags to the chart's appVersion, but a chart can float them. Set image tags explicitly in values when reproducibility matters.
What happens if the chart repository is unreachable?
Chart fails at preview, because rendering happens locally before anything is applied. That is a feature: the failure occurs before the target cluster is touched.
Why does helm list show nothing after a successful deploy?
Because Chart never creates a Helm release. It renders the templates and submits the objects itself, so there is no sh.helm.release.v1 secret for helm list to find. Use pulumi stack --show-urns instead; if you need helm list to work, that is a reason to choose Release.
Can I move an installed chart from Chart to Release?
Not in place. The two model completely different resources, so Pulumi plans a delete of every rendered object followed by a Helm install. Schedule it as a controlled reinstall, and check first whether the workload tolerates the gap — for an ingress controller it usually does not.
Related
- Managing Kubernetes CRDs with Pulumi Python — how to handle the custom resources charts install but do not upgrade.
- Managing Kubernetes with the Pulumi Python Provider — the parent topic on provider behaviour and authentication.
- Pulumi Component Resources — wrapping a chart plus its configuration into one reusable unit.