Managing Kubernetes with the Pulumi Python Provider

Once a managed cluster exists, the interesting problem starts: getting workloads onto it without hand-applied YAML drifting away from what the repository claims. This section, part of Pulumi patterns and provider management, covers the Pulumi Kubernetes provider in Python — how it authenticates, how it decides a rollout succeeded, how it shares ownership of a live object with the controllers that also write to it, and how it treats resources the API server owns rather than you.

Problem Framing

Kubernetes is unlike every other provider Pulumi talks to. Its API server mutates the objects you submit — filling defaults, assigning cluster IPs, injecting labels — so what you read back never matches what you sent. It is also asynchronous: kubectl apply returns as soon as the object is accepted, long before a pod is actually running. A deploy tool that reports success at acceptance time will happily mark a crash-looping rollout as green.

Why Kubernetes is different Why Kubernetes is different: API server with 4 facets. API server Defaulting fills unset fields Admission mutates on write Async accepts before ready Controllers own some fields
The object you read back is never the object you sent, which is why naive diffing produces noise.

The Pulumi provider addresses both. It performs server-side field management so provider-set defaults are not treated as drift, and it waits on readiness conditions before completing a resource, so pulumi up finishing means the workload is genuinely up. Understanding these two behaviours explains nearly every surprising diff you will see.

There is a third property that catches people later: a Kubernetes object is a shared document. An AWS S3 bucket has exactly one writer, but a Deployment in a live Kubernetes cluster may be written by your Pulumi program, by the deployment controller, by a horizontal pod autoscaler, by a mutating admission webhook that injects a service-mesh sidecar, and by whoever last ran kubectl edit at 3 a.m. Any tool that assumes sole ownership will fight the others forever. The provider's answer is to record which fields it owns and leave the rest alone, which is the subject of the next two sections.

Prerequisites

  • Python 3.9+ with pulumi>=3.0 and pulumi-kubernetes>=4.0 pinned
  • A reachable cluster and a kubeconfig — for instance the one exported by an AKS cluster or a GKE cluster
  • RBAC rights for the objects the stack manages, plus read access to the objects the provider watches while awaiting readiness
  • kubectl locally for verification, and a Kubernetes API server at 1.22 or newer so server-side apply behaves as documented
# CLI: confirm the Kubernetes cluster is reachable, which context is active, and the server version
kubectl config current-context && kubectl get --raw /readyz
kubectl version -o json | python3 -c 'import json,sys; print(json.load(sys.stdin)["serverVersion"]["gitVersion"])'

Pin the provider plugin as well as the Python package. The Python SDK and the provider binary ship as a matched pair, and a stack that resolves a newer plugin than the one you tested against can change diff behaviour on an otherwise unmodified program.

How the Provider Authenticates

The provider reads an ambient kubeconfig by default, which is convenient locally and dangerous in CI — whichever context happens to be current becomes the deploy target. Pass the credential explicitly instead, taking it from the stack that created the target cluster.

Credential path Credential path: cluster stack then stack output then StackReference then k8s.Provider then resources cluster stack creates cluster stack output kubeconfig, secret StackReference typed read k8s.Provider explicit target resources opts=provider
The kubeconfig travels from the Kubernetes cluster stack to an explicit provider — never through ambient CLI state.
# provider.py — an explicit provider, not the ambient kubeconfig
# CLI: pulumi up
import pulumi
import pulumi_kubernetes as k8s

cluster_stack = pulumi.StackReference("acme/platform-cluster/prod")
kubeconfig: pulumi.Output[str] = cluster_stack.get_output("kubeconfig")

k8s_provider = k8s.Provider(
    "prod",
    kubeconfig=kubeconfig,
    namespace="apps",                    # default namespace for namespaced kinds
    enable_server_side_apply=True,       # explicit, even though v4 defaults to it
)
# Provider note: every resource below must pass this provider explicitly via
# ResourceOptions, or it silently targets whatever kubeconfig the machine has.

Drawing the kubeconfig from a stack reference means the workload stack cannot target a Kubernetes cluster the infrastructure stack did not create. Three details matter in practice. First, the kubeconfig must be marked secret in the producing stack, or the credential ends up in plaintext in the state file. Second, a kubeconfig containing an exec credential plugin (the shape that AKS, EKS, and GKE all emit) requires that binary — kubelogin, aws, gke-gcloud-auth-plugin — to exist on the runner; without it the provider fails before it makes a single API call. Third, k8s.Provider accepts context and cluster arguments that select from a multi-context kubeconfig, which is safer than mutating the file with kubectl config use-context in a CI script.

If the credential expires mid-apply — common with short-lived tokens on long rollouts — you will see Unauthorized on a resource halfway through the update rather than at the start. Prefer credentials whose lifetime comfortably exceeds the longest rollout the stack can produce.

Server-Side Apply and Field Managers

Version 4 of the provider uses server-side apply by default. Instead of sending a full object and letting the API server overwrite everything, the provider sends a patch with a field manager name and content type application/apply-patch+yaml. The API server records, in metadata.managedFields, exactly which field paths each manager owns. A field you set in your Pulumi program is owned by the provider's manager; a field the deployment controller writes is owned by kube-controller-manager; a sidecar container inserted by a mutating webhook is owned by that webhook's manager.

Field managers on one Deployment Field managers on one Deployment: managedFields with 4 facets. managedFields pulumi-kubernetes fields your program sets kubectl leftover manual edits controller replicas and status webhook injected sidecar entries
Ownership is recorded per field path, so two writers can share one object until they collide.

Two consequences follow, and both are improvements over the old client-side merge. Removing a field from your Python program now actually removes it from the live object, because the provider's apply no longer lists that path and the API server drops any field left with no owner. And a field you never mentioned stays untouched, because you never claimed it.

Inspect ownership directly when a diff surprises you:

# CLI: see which manager owns which part of the live Deployment
kubectl -n apps get deployment api -o json \
  | python3 -c 'import json,sys; [print(e["manager"], e["operation"], sorted(e["fieldsV1"].get("f:spec", {}))) for e in json.load(sys.stdin)["metadata"]["managedFields"]]'

How a field-manager conflict surfaces

A conflict happens when your apply claims a field path another manager already owns with a different value. The API server rejects the whole request with HTTP 409 and a message naming the other manager and the exact path. Pulumi surfaces it against the resource URN, roughly like this:

# CLI: pulumi up  (abridged output of a real field-manager conflict)
#   kubernetes:apps/v1:Deployment (api):
#     error: Apply failed with 1 conflict: conflict with
#     "kubectl-client-side-apply" using apps/v1: .spec.replicas
#     Another field manager owns this field. To take ownership, set the
#     annotation pulumi.com/patchForce: "true" on the resource, or re-run
#     with PULUMI_K8S_ENABLE_PATCH_FORCE=true.

The manager name is the diagnosis. kubectl-client-side-apply means someone ran a plain kubectl apply or kubectl edit against an object the stack owns. kube-controller-manager on .spec.replicas means a horizontal pod autoscaler is scaling the workload while your program also declares replicas. istio-sidecar-injector on a container path means a mutating webhook and your program are both writing the pod template's container list.

Each cause has a different correct fix, and forcing is only right for the first one:

# force.py — take ownership back from a stale kubectl edit, deliberately
# CLI: pulumi up
import pulumi
import pulumi_kubernetes as k8s

deployment = k8s.apps.v1.Deployment(
    "api",
    metadata={
        "namespace": "apps",
        "annotations": {"pulumi.com/patchForce": "true"},
    },
    spec={"replicas": 3, "selector": {"match_labels": {"app": "api"}},
          "template": {"metadata": {"labels": {"app": "api"}},
                       "spec": {"containers": [{"name": "api",
                                                "image": "ghcr.io/acme/api:1.4.2"}]}}},
    opts=pulumi.ResourceOptions(provider=k8s_provider),
)
# Provider note: patchForce makes the provider send force=true, seizing every
# conflicting path. It resolves the symptom permanently and hides future ones,
# so scope it to one resource rather than setting the environment variable.

If the real conflict is an autoscaler, the fix is to stop declaring replicas at all, so the field has no Pulumi owner and the autoscaler's value survives. If it is a sidecar injector, leave the injected container out of your template and let the webhook add it. Forcing in either case produces a flapping object: your apply wins, the controller re-writes the field seconds later, and the next preview shows the same diff again.

How Pulumi Diffs Against a Mutated Live Object

The reason previews are trustworthy is that the provider does not compute them alone. During pulumi preview it sends the same apply patch to the API server with ?dryRun=All, which runs defaulting, admission, and validation without persisting anything, and returns the object as it would exist. The provider then diffs that projected object against the current live object, restricted to the field paths its own manager owns.

How preview computes a diff How preview computes a diff: program inputs then dry-run apply then returned object then ownership filter then rendered diff program inputs desired object dry-run apply dryRun=All returned object server-defaulted ownership filter managedFields rendered diff preview output
The API server, not the provider, decides what the object would become; Pulumi diffs that answer.

That design has practical implications worth internalising. A preview against a Kubernetes provider is a network operation: it needs a reachable API server and the same RBAC as an apply, so an offline pulumi preview on a Kubernetes stack cannot work the way it does for a cloud provider whose diff is computed locally. It is also why a preview catches validation errors early — an invalid resources.limits quantity or a rejected admission policy fails at preview, not thirty seconds into the apply.

The dry run also explains why defaulted fields do not show up as changes. spec.strategy.type: RollingUpdate, spec.revisionHistoryLimit: 10, and a Service's assigned clusterIP all appear in the projected object and in the live object identically, and none of them is owned by the provider's field manager, so all three are filtered out before rendering. Under the old client-side merge those same fields produced the endless phantom diffs that gave Kubernetes-plus-IaC its reputation.

Where it still goes wrong is a webhook whose output is not deterministic. If a mutating webhook stamps a timestamp annotation or a randomly generated identifier on every write, the dry run returns a different value each time, and a field the provider does own will diff on every preview. The fix is to make the webhook idempotent or to exclude the object from it; there is nothing the provider can do about a server that answers differently to the same question.

Choosing an Entry Point: Manifests, Charts, and Releases

Not every workload is worth writing out as typed Python resources. The provider offers four ways to bring in objects someone else authored, and they differ in where the templating happens and what ends up in state.

Kubernetes entry points compared Kubernetes entry points compared: comparison across Renders where, Pulumi resources, Cluster state. Entry point Renders where Pulumi resources Cluster state yaml.ConfigFile one manifest file one per document plain objects yaml.ConfigGroup globs and strings one per document plain objects helm.v3.Chart locally, in process one per document no release secret helm.v3.Release by the Helm SDK one opaque resource release secret
Chart and Release install the same software but leave very different traces in state and in the target cluster.

yaml.ConfigFile reads one manifest file and turns every YAML document in it into an individually tracked Pulumi resource. yaml.ConfigGroup does the same for a list of file paths, glob patterns, or literal YAML strings, which is what you want for a directory of manifests or for YAML generated elsewhere in the program. Both accept a transformations argument — a list of callables that receive each parsed object as a dictionary before it is registered, so you can stamp a namespace or a label onto every document without editing the source YAML. In provider 4.x the newer yaml.v2.ConfigGroup handles unknown values during preview better than the original, at the cost of exposing the child resources slightly differently.

helm.v3.Chart runs the chart's templates locally, in the Pulumi process, and registers each rendered document as its own resource. Nothing Helm-specific reaches the target cluster: there is no release secret, helm list shows nothing, and chart hooks are not executed. What you get in return is a per-object preview — a chart upgrade shows exactly which ConfigMap key and which container image changed.

helm.v3.Release is the opposite trade. It drives the actual Helm SDK, so the target cluster gets a real release with its history secret, hooks run, and helm rollback works. In Pulumi's state it is a single resource whose inputs are the chart coordinates and the values; a preview can tell you the values changed but not what that will do to the rendered objects. Newer provider versions add helm.v4.Chart, which keeps local rendering but fixes the resource-naming and dependency-ordering rough edges of the v3 component.

The practical rule: use Chart when you want the objects to be first-class Pulumi resources you can transform, patch, and diff; use Release when the chart depends on Helm mechanics such as hooks or when another team expects helm commands to work. Deploying Helm charts with Pulumi Kubernetes Python works through both with real chart values.

Namespaces and the Provider's Default Namespace

Namespace resolution is the single most common source of "it deployed, but not where I expected". Four sources can supply the namespace for a namespaced object, and they are consulted in order.

Where a namespaced object lands Where a namespaced object lands: choose among 4 options. resolving metadata.namespace set the value inmetadata provider Provider namespacearg context kubeconfig context neither default namespace
Four sources can supply a namespace; only the first two are visible in the Pulumi program.

An explicit metadata.namespace on the resource always wins. Failing that, the namespace argument on k8s.Provider applies to every namespaced resource using that provider. Failing that, the provider uses the namespace baked into the selected kubeconfig context. Failing all three, the object lands in default — which is how workloads end up in the wrong place on a laptop whose context still points at a scratch namespace.

Set the namespace on the provider, and set it explicitly on resources whose namespace is created by the same program, so the dependency edge is real:

# namespaces.py — a created namespace, referenced as an Output
# CLI: pulumi up
from dataclasses import dataclass
import pulumi
import pulumi_kubernetes as k8s

@dataclass(frozen=True)
class NamespaceSpec:
    name: str
    istio_injection: bool

spec = NamespaceSpec(name="apps", istio_injection=True)

ns = k8s.core.v1.Namespace(
    "apps",
    metadata={
        "name": spec.name,   # pin the name; otherwise Pulumi auto-names it apps-7f3a91c
        "labels": {"istio-injection": "enabled" if spec.istio_injection else "disabled"},
    },
    opts=pulumi.ResourceOptions(provider=k8s_provider),
)
# State implication: without metadata.name Pulumi appends a random suffix, and every
# hard-coded reference to "apps" elsewhere in the program silently misses.

cfg = k8s.core.v1.ConfigMap(
    "api-config",
    metadata={"namespace": ns.metadata.name},   # Output, not the literal "apps"
    data={"LOG_LEVEL": "info"},
    opts=pulumi.ResourceOptions(provider=k8s_provider),
)
# Provider note: passing ns.metadata.name makes Pulumi create the namespace first
# and delete it last. A literal string would create both concurrently and fail with
# `namespaces "apps" not found`.

Two edge cases. Cluster-scoped kinds — Namespace, CustomResourceDefinition, ClusterRole, PersistentVolume, StorageClass — ignore the provider's namespace entirely; setting metadata.namespace on them is accepted and then dropped by the API server, which shows up as a diff that never settles. And deleting a namespace deletes everything in it, so a pulumi destroy that removes the Namespace resource can wipe objects other stacks created there. If the namespace is shared, adopt it with opts=pulumi.ResourceOptions(retain_on_delete=True) or do not manage it in the workload stack at all.

RBAC for the Deploying Identity

The identity in the kubeconfig needs more than write access to the kinds it declares, because awaiting readiness means watching objects the program never mentions.

RBAC the deploying identity needs RBAC the deploying identity needs: layered from create, update, patch, delete down to cluster-scoped rights. create, update, patch, delete on every kind the stack manages get, list, watch on pods, replicasets, endpoints escalate and bind only when the stack manages RBAC itself cluster-scoped rights CRDs, ClusterRoles, namespaces
Awaiting a rollout needs read access to objects the program never declares.

patch is mandatory under server-side apply — an identity with create and update but no patch fails on every resource, because an apply patch is a PATCH request. Readiness awaiting needs list and watch on pods, replicasets, and events in the target namespace, plus endpoints if the stack creates Services; without them the apply either hangs or reports a confusing forbidden error while the object itself was created successfully.

Check the identity's rights before debugging anything else:

# CLI: confirm the deploying identity can do what the provider needs
kubectl auth can-i patch deployments --namespace apps \
  --as system:serviceaccount:ci:deployer
kubectl auth can-i watch pods --namespace apps \
  --as system:serviceaccount:ci:deployer
kubectl auth can-i create customresourcedefinitions \
  --as system:serviceaccount:ci:deployer

A missing right produces an error naming the exact resource, group, and namespace, for example deployments.apps is forbidden: User "system:serviceaccount:ci:deployer" cannot patch resource "deployments" in API group "apps" in the namespace "apps". Read it literally: the verb and the namespace in that string are what the Role must grant.

RBAC that manages RBAC has an extra rule. Kubernetes forbids an identity from granting permissions it does not itself hold, so a stack that creates Roles fails with roles.rbac.authorization.k8s.io "app-reader" is forbidden: user "deployer" (groups=["system:authenticated"]) is attempting to grant RBAC permissions not currently held. The two escapes are to give the deployer the union of every permission it will ever grant, or to grant it the escalate verb on roles and bind on clusterroles. The second is shorter but effectively grants the deployer administrative reach over the whole Kubernetes cluster, so prefer the first for anything that runs unattended in CI. The same least-privilege reasoning that applies to IAM least privilege applies here.

Ordering, CRDs, and depends_on

Pulumi derives ordering from data flow: pass one resource's Output into another and the edge exists. Custom resource definitions break that model, because the dependency is not on a value but on the API server having learned a new kind.

CRD registration before first use CRD registration before first use: Pulumi → API server → apiextensions → operator. Pulumi API server apiextensions operator apply CRD register kind Established apply instance reconcile Ready
The custom kind must be Established before the API server will accept an instance of it.

The provider waits for a CustomResourceDefinition to report the Established and NamesAccepted conditions before marking it created, which makes it a usable ordering anchor. Express the edge with depends_on:

# ordering.py — custom resources cannot precede their definition
# CLI: pulumi up
import pulumi
import pulumi_kubernetes as k8s

crds = k8s.yaml.ConfigGroup(
    "cert-manager-crds",
    files=["manifests/cert-manager.crds.yaml"],
    opts=pulumi.ResourceOptions(provider=k8s_provider),
)

issuer = k8s.apiextensions.CustomResource(
    "letsencrypt",
    api_version="cert-manager.io/v1",
    kind="ClusterIssuer",
    metadata={"name": "letsencrypt"},
    spec={"acme": {"server": "acme-v02.api.letsencrypt.org/directory",
                   "private_key_secret_ref": {"name": "letsencrypt-key"},
                   "solvers": [{"http01": {"ingress": {"class": "nginx"}}}]}},
    opts=pulumi.ResourceOptions(provider=k8s_provider, depends_on=[crds]),
)
# Provider note: depends_on is required because no value flows from the CRD to the
# instance. Without it the apply fails with:
#   no matches for kind "ClusterIssuer" in version "cert-manager.io/v1"

depends_on fixes the apply, but a first pulumi preview against a Kubernetes cluster where the CRD does not yet exist can still fail: the dry-run apply needs a schema the API server has never seen. Recent provider versions detect that the missing kind is defined by a CRD in the same program and emit a warning instead of an error, but the robust arrangement for anything operator-shaped is two stacks — one that installs the operator and its definitions, one that creates instances — joined by a stack reference. Managing Kubernetes CRDs with Pulumi Python covers both arrangements in detail.

Deletion runs the graph backwards, and that is where CRDs bite hardest. Removing a CustomResourceDefinition causes the API server to garbage-collect every instance of that kind across the whole Kubernetes cluster, including instances no Pulumi stack created. Treat a CRD removal as a destructive migration and guard it with retain_on_delete until you have confirmed nothing else uses the kind.

Step-by-Step: Deploying a Workload

Declare a namespace, then a deployment and a service inside it. Passing the parent namespace as an Output — rather than a literal string — makes the dependency real, so Pulumi orders creation correctly.

Rollout awaiting Rollout awaiting: Pulumi → API server → ReplicaSet → Pods. Pulumi API server ReplicaSet Pods apply deployment create replicas schedule pods readiness passes resource ready
Pulumi holds the resource open until readiness reports back, so a green apply means a live workload.
# workload.py — namespace, deployment, and service on an explicit provider
import pulumi
import pulumi_kubernetes as k8s

opts = pulumi.ResourceOptions(provider=k8s_provider)

ns = k8s.core.v1.Namespace(
    "apps", metadata={"name": "apps"}, opts=opts)

labels: dict[str, str] = {"app": "api"}

deployment = k8s.apps.v1.Deployment(
    "api",
    metadata={
        "namespace": ns.metadata.name,
        "annotations": {"pulumi.com/timeoutSeconds": "300"},
    },
    spec={
        "replicas": 3,
        "selector": {"match_labels": labels},   # immutable after creation
        "template": {
            "metadata": {"labels": labels},
            "spec": {"containers": [{
                "name": "api",
                "image": "ghcr.io/acme/api:1.4.2",   # a tag, never :latest
                "ports": [{"container_port": 8080}],
                "readiness_probe": {
                    "http_get": {"path": "/healthz", "port": 8080},
                },
            }]},
        },
    },
    opts=opts,
)
# State implication: pulumi up does not return until the readiness probe passes
# on the required number of replicas, so a failing rollout fails the deploy.

service = k8s.core.v1.Service(
    "api",
    metadata={"namespace": ns.metadata.name, "name": "api"},
    spec={
        "selector": labels,          # must match the pod template labels exactly
        "ports": [{"port": 80, "target_port": 8080}],
    },
    opts=opts,
)
# Provider note: spec.clusterIP is assigned by the API server and owned by it,
# so leave it unset — claiming it makes every future apply a replacement.

Provider note: spec.selector.match_labels is immutable. Changing it replaces the deployment, which means a brief outage unless you stage the change through a second deployment.

The readiness contract for a Deployment is precise, and knowing it removes most of the mystery from a slow apply. The provider waits until status.observedGeneration catches up to metadata.generation, until status.updatedReplicas equals the desired replica count, until status.readyReplicas matches, and until the Progressing condition reports reason NewReplicaSetAvailable. Each of those can stall for a different reason, and the provider streams the blocking one into the CLI while it waits. Awaiting Kubernetes rollouts in Pulumi Python covers the timeout and opt-out knobs; deploying a Python app to Kubernetes with Pulumi walks the same pattern end to end with an application image.

Verification

Check the target cluster's own view rather than trusting the deploy summary, and confirm the rollout actually converged.

# CLI: rollout complete and endpoints populated
kubectl -n apps rollout status deployment/api --timeout=120s
kubectl -n apps get endpoints api -o jsonpath='{.subsets[*].addresses[*].ip}'
pulumi stack --show-urns | grep kubernetes | head

An endpoints list with as many addresses as replicas means traffic will actually be served. An empty list with a healthy deployment usually means the service selector does not match the pod labels.

Two further checks are worth adding to a review checklist. Run pulumi preview --diff immediately after a successful pulumi up: on a healthy stack it must report no changes, and any field that reappears identifies either a controller writing over you or a non-deterministic admission webhook. And confirm the provider's field manager is present and owns what you expect:

# CLI: the stack's field manager should own the fields the program declares
kubectl -n apps get deployment api -o jsonpath='{range .metadata.managedFields[*]}{.manager}{"\t"}{.operation}{"\n"}{end}'
kubectl -n apps get deployment api -o jsonpath='{.status.conditions[?(@.type=="Available")].status}'

An Apply operation from the Pulumi provider's manager alongside an Update operation from kube-controller-manager is the normal, healthy shape. An additional kubectl-client-side-apply entry is the fingerprint of out-of-band changes and a conflict waiting to happen.

Troubleshooting

Apply hangs, then times out — the readiness probe never passes. Cause: the probe path or port is wrong, the image is crash-looping, or no node can satisfy the pod's resource requests. The provider prints the blocking sub-object while it waits, for example [Pod apps/api-7d9f5c8b4-xk2ml]: containers with unready status: [api] -- ImagePullBackOff: Back-off pulling image "ghcr.io/acme/api:1.4.2". On expiry it fails with Timeout occurred for 'api' followed by [MinimumReplicasUnavailable] Deployment does not have minimum availability. Fix: kubectl -n apps describe pod and kubectl -n apps logs --previous; the provider is reporting a genuine problem, not a bug.

Apply failed with 1 conflict: conflict with "…" — another field manager owns the path named at the end of the message. Fix: identify the manager first. A stale kubectl edit deserves pulumi.com/patchForce; an autoscaler or an injecting webhook deserves that you stop declaring the contested field.

Perpetual diff on a field you never set — a controller or an admission webhook writes it on every request, and the dry run returns a different value each time. Cause: service-mesh sidecar injection, a policy engine stamping annotations, or a webhook that adds a generated identifier. Fix: leave the field unset so the provider does not claim ownership, or exclude the object from the webhook.

field is immutable — the change requires replacement. The full message names the path, as in Deployment.apps "api" is invalid: spec.selector: Invalid value: v1.LabelSelector{...}: field is immutable, or Service "api" is invalid: spec.clusterIP: Invalid value: "": field is immutable. Fix: rename the Pulumi resource to force a create-before-delete, or accept the replacement in a maintenance window.

no matches for kind "X" in version "…" — the API server has never heard of the kind. Cause: a custom resource applied before its definition, or an object from an API version removed in a newer Kubernetes release, such as a manifest still using networking.k8s.io/v1beta1. Fix: add depends_on on the CRD resource, or update the manifest's apiVersion.

configured Kubernetes cluster is unreachable — the provider could not reach the API server at all, usually followed by a dial error naming the endpoint. Cause: a private control-plane endpoint with no network path from the runner, an expired or missing exec credential plugin, or a kubeconfig whose certificate authority no longer matches a rebuilt Kubernetes cluster. Fix: run kubectl get --raw /readyz from the same runner with the same kubeconfig before blaming Pulumi.

… is forbidden: User "…" cannot patch resource "deployments" — RBAC. Fix: grant patch in addition to create and update, and confirm with kubectl auth can-i --as.

Resources land in the wrong Kubernetes cluster — a resource omitted opts=ResourceOptions(provider=...) and fell back to the ambient kubeconfig. Fix: pass the provider on every resource, or set it once as a default via a parent component resource.

A namespace hangs in Terminating — a finalizer on an object inside it has not completed, commonly an operator's custom resource whose controller is already gone. pulumi destroy stalls on the Namespace resource until the finalizer clears. Fix: remove the finalizer on the blocking object, then re-run the destroy.

FAQ

Why does pulumi up take longer than kubectl apply?

Because it waits. kubectl apply returns when the API server accepts the object; Pulumi returns when the object reports ready. The extra time is the rollout you were previously not observing.

Can I disable the readiness wait?

Yes, with the pulumi.com/skipAwait annotation on a resource. It is occasionally necessary for objects with no meaningful ready condition, but skipping it broadly turns a deploy tool back into a submit tool.

Should Kubernetes resources live in the same stack as the Kubernetes cluster?

No. Keep the target cluster in one stack and workloads in another, joined by a stack reference. Otherwise every application deploy carries the risk of a control-plane change.

Does the provider support server-side apply?

Yes, and it is the default from version 4 onward. It is what allows the provider to distinguish fields you own from fields a controller set, and what makes removing a field from your program actually remove it from the live object.

How do I adopt an object that already exists in the target cluster?

Import it with opts=pulumi.ResourceOptions(import_="apps/api") so Pulumi records the live object as the resource's initial state, then reconcile your program until pulumi preview is empty. Under server-side apply the first apply after an import will also transfer ownership of the fields your program declares.

Why does pulumi preview need network access when other providers do not?

Because the diff is computed by the API server, not locally. The provider sends a dry-run apply and diffs the object the server says it would produce, which is exactly why defaulted and webhook-mutated fields do not show up as spurious changes.