In February 2025, Pulumi quietly closed one of the oldest gaps between two religions of infrastructure as code. The 2.0 release of the Pulumi Kubernetes Operator — in beta since KubeCon Salt Lake City in November 2024, generally available as of February 20, 2025 — moved every Pulumi program's execution out of the operator's own pod and into a dedicated per-stack Workspace pod, coordinated through two new custom resources (Workspace and Update) while keeping the Stack CRD as the stable interface. In plain terms: a pulumi up is now something a Kubernetes control plane schedules, RBACs, audits, and garbage-collects like any other workload.
Here is our verdict up front: for a Cluster API fleet, Operator 2.0 is worth adopting when your management cluster is already the front door to fleet operations — because a Stack object sitting next to your Cluster and MachineDeployment objects unifies who-may-change-what, what-changed-when, and what-drifted into one API server. It is redundant with Crossplane only if your platform team authors infrastructure exclusively in YAML and needs nothing Pulumi's language ecosystem, provider breadth, or per-stack secrets model provides. The table below is the whole argument; the rest of this post is the evidence.
| Dimension | Pulumi CLI + backend | PKO 2.0 Stack CRD | Crossplane |
|---|---|---|---|
| State home | Pulumi Cloud or self-managed backend (S3, Azure Blob, GCS, local files) | Same backends — etcd is not the state | etcd: the cluster is the state |
| Auth / blast radius | Whoever holds the credential running the CLI | Per-stack serviceAccountName on the Workspace pod; one stack's cloud keys never touch another's pod | Per-provider credentials; RBAC on claims and composites |
| Authoring | TypeScript, Python, Go, .NET, Java, YAML, HCL | Same programs, triggered by Git/CR change instead of a human | YAML compositions (+ Go composition functions) |
| Drift handling | preview/refresh on demand; Deployments for scheduled checks | Resync-interval reconciliation + refresh flag; Argo CD/Flux drive the Stack objects | Controllers reconcile continuously by construction |
| Audit trail | CI logs plus Pulumi Cloud history | API-server audit log: the node rollout and the infra change share one timeline | API-server audit log, same property |
| kubectl / GitOps visibility | None natively — separate dashboard | kubectl get stacks, controller events, Argo-managed Stack objects | kubectl get on managed and composite resources |
What 2.0 actually changed (and what 1.x cost you)
Under Operator 1.x, your Pulumi program ran inside the operator pod itself, under the operator's own service account. That single design decision produced three concrete pains, all documented in the project's own issue tracker: every stack shared one identity (so the credential that could touch stack A's cloud account was mounted next to stack B's), every stack shared one resource pool (a hungry pulumi preview could starve the controller reconciling everything else), and scaling meant vertically fattening one Deployment — operators running 20–30 stacks were asking for multi-replica support just to spread the load across nodes.
2.0 inverts this. Each Stack gets its own Workspace pod (backed by a StatefulSet), its own Workspace object managing that pod's lifecycle, and an Update object per operation so kubectl describe shows the history of what ran, not just the current desired state. The program runs as the serviceAccountName you name in the spec — least privilege per stack, configured in RBAC you already know how to review.
Cloud tokens arrive via envRefs from Secrets you control, the runner image is pinned in workspaceTemplate, and workspaceReclaimPolicy: Retain | Delete decides whether the workspace pod sticks around for debugging after a successful run (the default, Retain) or is cleaned up automatically. The Stack CRD API itself (pulumi.com/v1) stayed backward compatible, and Pulumi ships a step-by-step migration guide from 1.x — the upgrade is an architecture migration, not a rewrite of your programs.
A minimal stack now reads like any other piece of fleet config:
apiVersion: pulumi.com/v1
kind: Stack
metadata:
name: tenant-networking
namespace: fleet-infra
spec:
stack: prod
projectRepo: https://github.com/example-org/fleet-stacks
branch: main
refresh: true
destroyOnFinalize: true
serviceAccountName: stack-tenant-networking
envRefs:
PULUMI_ACCESS_TOKEN:
type: Secret
secret:
name: pulumi-api-secret
key: accessToken
workspaceTemplate:
spec:
image: pulumi/pulumi:3.147.0-nonrootApply that and the event stream tells the story the old model never could: StackUpdateDetected, workspace pod Initialized, UpdateSucceeded, StackCreated — ordinary controller events you can alert on with the same pipeline that watches MachineDeployment rollouts.
The Cluster API payoff: one API server for machines and the infra under them
Generic Kubernetes RBAC and audit logs are nice. The reason this matters specifically to a CAPI fleet is co-location: the management cluster already holds your Cluster, MachineDeployment, and MachineHealthCheck objects. Stack objects land in that same etcd, behind that same API server, and four things follow that no CLI-plus-backend workflow can give you:
- One RBAC model for "roll the nodes" and "touch the infra." The role that may update a
MachineDeploymentand the role that may trigger aStackupdate are reviewed in the same YAML, bound to the same subjects. There is no second identity system in CI holding a god-credential whose scope you audit separately. - One audit timeline. When a tenant outage traces back to "the load balancer changed ten minutes before the nodes rolled," both events — the
Stackupdate and theMachineDeploymentrollout — sit in the API-server audit log with the same timestamps and actor identities. No joining CI job logs to cluster logs after the fact. - Single-pane reads.
kubectl get stacks,clusters -n fleet-infraplus one event watch replaces the two-dashboard shuffle (Pulumi Cloud in one tab,kubectlin another) for the daily "is the fleet converging?" check. Drift in machine state and drift in infra state show up in the same stream, corrected by sibling controllers that each own their domain. - One GitOps sync moves fleet and infra as a unit. Argo CD already manages your CAPI manifests; now it manages the
Stackobjects too (the operator documents Argo CD and Flux-source patterns explicitly), so a single commit can advance the machine template and the infrastructure program together, with each controller reconciling its half. Version skew between "what the nodes expect" and "what the infra provides" becomes a rollout-ordering problem you solve once, not a standing hazard.
Note what 2.0 does not do: it does not move Pulumi state into etcd. Your stack state still lives in Pulumi Cloud or your own object-storage backend, encrypted per stack with pluggable KMS. The CRD is the control surface, not the datastore. Teams that hear "IaC as CRDs" and assume the cluster now owns state should internalize this boundary before adopting — it is the single most important row in the table above.
Where Crossplane already covers this (and where it doesn't)
Pulumi's own comparison documentation is unusually candid about the overlap: with the operator installed, both systems reconcile Kubernetes objects toward cloud reality on a resync loop, both ride Argo CD and Flux, and neither offers transactional rollback (a failed Pulumi update leaves a partially-updated stack reconciled forward on the next run; a failed Crossplane apply retries until convergence — roll forward either way). Crossplane is a graduated CNCF project whose v2 release made composites namespaced, dropped the old claim concept, and put programmatic logic in composition functions. If your platform team thinks natively in YAML and your tenants consume infrastructure through opinionated platform APIs you design, Crossplane's composition model is the narrower, more Kubernetes-native tool, and adding Pulumi on top buys you little.
The differences that survive contact with a real fleet are four:
- Authoring. Pulumi programs are real programs: loops, conditionals, types,
npm/PyPI/Go modules, IDE refactoring, and the test frameworks your team already runs in CI. Crossplane compositions are YAML until they aren't — the escape hatch is a composition function written in Go and run inside the reconciliation pipeline. If the people writing infrastructure are application developers, this is the deciding factor, not a footnote. - Provider breadth. Pulumi's registry includes native providers generated from upstream API schemas (Kubernetes, Azure Native) plus a bridge that turns any OpenTofu/Terraform provider into a typed SDK. Crossplane's providers are cluster-installed packages, many generated from Terraform providers via Upjet. Count the SaaS APIs and niche resources your stacks touch; the longer the tail, the more this matters.
- Secrets. Pulumi encrypts secret values in state per stack with pluggable KMS (AWS KMS, Azure Key Vault, GCP KMS, Vault), and anything derived from a secret stays encrypted. Crossplane stores credentials and connection details as standard Kubernetes Secrets — exactly as protected as your cluster's secret configuration (etcd encryption at rest, external secrets store) makes them. On a multi-tenant fleet, "encrypted by the tool" versus "encrypted if the cluster is configured right" is a real operational gap.
- Policy. Pulumi ships open-source policy-as-code in TypeScript, Python, or Rego. Crossplane leans on admission control (Gatekeeper, Kyverno) applied to its resources. Both work; only one travels with the program.
Decision rule: if your platform exposes infrastructure to tenants as curated YAML APIs and your team is fluent in composition thinking, Crossplane alone is coherent — PKO 2.0 is redundant. If infrastructure is authored by developers in general-purpose languages, touches a long tail of providers, or needs per-stack encrypted secrets with policy that lives next to the code, PKO 2.0 is additive, not duplicative. Running both is legitimate (Pulumi programs can install and operate Crossplane itself via the Kubernetes provider) but should be a conscious layering choice, not an accident.
The agent-operability read
The core question behind this topic is whether "the agent reads/writes the same Kubernetes objects a human would" needs Pulumi's stack semantics on top of Crossplane's. The honest answer splits by interface. Crossplane's programmatic interface is the Kubernetes API: an agent that can apply a composite resource and get its status needs no new SDK, and everything it does is RBAC-scoped and audit-logged by construction. Pulumi's native agent interface is the Automation API — an SDK for driving preview/up/destroy from a host process — which is powerful but lives outside the API server's policy envelope.
What PKO 2.0 changes is that the Kubernetes API becomes a first-class Pulumi interface too: an agent holding a namespace-scoped token can create a Stack, set refresh: true, and read Update status, all under the same RBAC and audit you enforce on humans — and the per-stack service account bounds the blast radius of whatever the agent triggers to exactly that stack's cloud permissions. For a platform treating AI agents as operators, that boundary is the feature: grant the agent rights on stacks in one namespace, not a CI credential that can up everything. Crossplane gives you the same shape for free; PKO 2.0 gives you the same shape for Pulumi programs. Choose the one whose authoring layer your humans already use — the agent inherits the decision.
Costs, caveats, and who should skip it
- It is another control plane on your management cluster. Workspace pods consume real CPU and memory per stack, and at fleet scale you are scheduling dozens of StatefulSets that each pull a full Pulumi toolchain image. Budget node headroom for the reconciler fleet, not just the tenant fleet — and set
workspaceReclaimPolicy: Deleteon stacks you don't need to post-mortem. - State still lives outside etcd. Backup and disaster-recovery planning must cover the Pulumi backend (Cloud or your buckets) in addition to management-cluster etcd. A cluster restore without the state backend restores the desire without the memory.
- No transactional rollback, ever. Both systems roll forward. If your change process assumes "failed apply returns us to the previous state," fix the process before adopting either tool.
- 1.x migration is real work. New CRDs, per-stack service accounts and Secrets, workspace image pinning, and re-verified resync intervals per stack. The migration guide exists — schedule the migration, don't squeeze it between releases.
- Skip it if: a single CLI run in CI already covers your stack count with readable audit; your team authors everything in YAML and is happy; or your management cluster is resource-thin enough that per-stack pods are a burden rather than a boundary. A tool that unifies operations is only a win if operations were actually fragmented.
Verdict
Pulumi Kubernetes Operator 2.0 earns its major version: workspace-per-stack execution with per-stack identity turns "run IaC from inside the cluster" from a shared-fate hack into an RBAC-shaped primitive, and parking Stack objects beside CAPI's own resources gives a Cluster API fleet one API server, one audit log, and one GitOps sync for machines and the infrastructure under them. It converges with Crossplane at the reconciliation layer and stays distinct at the authoring, provider, secrets, and policy layers — redundant for YAML-native platform teams, additive for developer-authored, long-tail infrastructure. And for agent-operators, the prize is the same either way: least-privilege, audit-logged infrastructure changes through objects both humans and machines already understand.
Adopt the interface your humans use; the agents will inherit it. Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own. Star the repo on GitHub or deploy your first app today.



