Pulumi Patterns & Provider Management

Pulumi lets you define cloud infrastructure in real Python—typed, tested, and packaged like any other application code—instead of a bespoke declarative DSL. This section covers the patterns that make Pulumi Python production-grade: provider lifecycle management, stack architecture, reusable components, the Automation API, dynamic providers, and policy as code with CrossGuard. It also covers the four provider surfaces most Python teams actually touch—AWS, Azure, GCP, and Kubernetes—plus the secrets and configuration machinery that feeds all of them. It sits alongside the CDKTF workflows and Terraform synthesis approach and the broader Python IaC fundamentals and strategy guidance, and is the home for every Pulumi-specific technique on this site.

Pulumi execution model A Python program feeds resource declarations to the Pulumi engine. The engine reconciles desired state against the state backend, then drives provider plugins that call cloud APIs. Python program __main__.py Pulumi engine deploy / preview Provider plugins aws, gcp, k8s Cloud APIs State backend S3 / Pulumi Cloud
Pulumi runs your Python program, reconciles desired against recorded state, and drives provider plugins to call cloud APIs.

Why This Matters

Pulumi collapses two things a declarative tool keeps apart: the language that describes resources and the language that computes them. That is both the benefit and the risk. A loop over a list of subnet CIDRs is ordinary Python—testable, reviewable, refactorable—but it is also live logic that executes on every preview, in CI, under whatever credentials the runner happens to hold. The patterns in this section exist to keep that power predictable.

Three properties of the execution model drive everything else on this page.

Identity is a program-level decision. If a resource is constructed without an explicit provider in its ResourceOptions, the engine resolves a default provider for that package from stack configuration and ambient environment variables. Nothing fails, nothing warns—the resource is simply created wherever AWS_PROFILE, ARM_SUBSCRIPTION_ID, or the current kubeconfig context happens to point. A staging deployment that lands in the production account is almost always a missing opts= argument, not a broken credential.

Logical names are contracts. Pulumi addresses every resource by a URN assembled from the project name, the stack name, the chain of parent types, and the logical name you passed as the first constructor argument—for example urn:pulumi:prod::platform::aws:s3/bucketV2:BucketV2::audit-logs. Renaming the Python variable costs nothing. Changing that first string tells the engine the old resource no longer exists and a new one should, which becomes a create-and-delete pair in the next update. This is why refactoring a component's internal naming is a state operation, not a cosmetic one, and why pulumi.ResourceOptions(aliases=[...]) exists.

The state record is the only memory. The engine has no way to know what it built other than the checkpoint written to the backend at the end of each update. Every idea in this section—one stack per environment, encrypted configuration, components that register their outputs, policy evaluated before the diff is applied—is ultimately about keeping that record accurate and keeping the number of people and pipelines that can rewrite it small.

A fourth property follows from the first three and is worth stating separately: the program runs on every operation, not only on up. pulumi preview, pulumi refresh, pulumi destroy and every policy evaluation all execute the same __main__.py. If the program reads a file from the developer's home directory, calls an internal service, or generates a random suffix without seeding it, that side effect happens six times a day in CI rather than once at creation. The rule that falls out is blunt: a Pulumi program should be a pure function of stack configuration plus the state backend, and anything else it needs should be an explicit input.

The payoff for getting these right is that infrastructure joins the same toolchain as the rest of your Python: mypy catches a mistyped resource argument at commit time, pytest with pulumi.runtime.Mocks asserts on the resource graph in milliseconds without touching a cloud API, and the CI pipeline diffs infrastructure the same way it diffs application code. The cost is that Python's flexibility is available to people who have not yet internalised the three properties above — which is exactly what the topics in this section are for.

Three properties that shape every Pulumi decision Three properties that shape every Pulumi decision: One pulumi up with 4 facets. One pulumi up Provider identity Resolved per resource, defaults to ambient credentials URN identity Logical name plus parent chain, not the variable State record The checkpoint is the engine's only memory Live process Your Python runs on every preview and update
Provider identity, URN identity, the state checkpoint and the live Python process are the four things every pattern in this section is trying to keep predictable.

The Paradigm Shift: From Declarative YAML to Programmatic Infrastructure

Static configuration files lack the expressiveness required for modern cloud architectures. Python developers adopt Pulumi to leverage mature package managers, IDE tooling, and robust testing frameworks. Programmatic IaC enables explicit control flow, strict type hints, and deterministic state validation. Teams must evaluate Pulumi against CDKTF based on existing cloud SDK dependencies and runtime overhead—Pulumi executes Python directly against cloud APIs, while CDKTF synthesizes Python to Terraform JSON first.

The mechanical difference is where evaluation stops. CDKTF runs your Python once to emit cdk.tf.json, then hands that artifact to Terraform; the plan is computed from a file you can read. Pulumi keeps the Python process alive for the whole update: the engine and your program talk over a gRPC channel, your program registers resources as it evaluates, and the engine answers with the resolved properties once the provider has created or updated the real resource. That is why Pulumi has Output[T] at all. A value like a VPC ID does not exist while the program is being evaluated, so the SDK hands you a future-like wrapper you transform with .apply() rather than a plain string you can interpolate.

The practical consequences of the live-process model show up quickly. You cannot branch on the value of an output—if vpc.id == "vpc-123" compares a wrapper object, not a string, and always takes the same path. You can branch freely on configuration, because configuration is a plain value read before any resource exists. Errors raised inside an .apply() callback surface during the update rather than at import time, so a KeyError in a lambda becomes a half-completed deployment instead of a failed preview. And because the program is a process, anything Python can do—reading a CSV of account IDs, calling an internal service registry, importing a shared package from your artifact repository—is available at declaration time, provided you accept that it becomes a dependency of every deployment.

Output[T] deserves its own paragraph because almost every early Pulumi mistake is an Output mistake. An output carries three things: a value that may not exist yet, the set of resources the value depends on, and a flag recording whether the value is secret. .apply() is the only way to reach inside it, and whatever you return from the callback is itself wrapped in an Output. pulumi.Output.all(a, b) waits on several at once and hands the callback a list; pulumi.Output.concat("https://", bucket.bucket_regional_domain_name) builds a string without a lambda. What you must not do is put an output inside an f-string. Pulumi cannot raise at that point—__str__ has to return something—so it returns a long diagnostic string beginning Calling __str__ on an Output[T] is not supported, and that text is what gets written into your tag, your IAM policy document, or your Kubernetes ConfigMap. The resource is created successfully with garbage in it, which is far worse than a crash.

The dependency-tracking half of Output is what removes most explicit ordering. When a subnet passes vpc_id=vpc.id, the engine records an edge from subnet to VPC and will not start the subnet until the VPC has returned an identifier. depends_on in ResourceOptions is only needed for ordering the engine cannot infer, which in practice means IAM propagation, DNS validation, and anything a cloud performs asynchronously behind a resource that has already reported success. Reaching for depends_on on every resource is a sign the outputs are being unwrapped too early, usually into plain strings held in local variables.

The Paradigm Shift: From Declarative YAML to Programmatic Infrastructure The Paradigm Shift: From Declarative YAML to Programmatic Infrastructure: layered from From Declarative YAML down to CDKTF. From Declarative YAML Python Pulumi Programmatic IaC CDKTF
The Paradigm Shift: From Declarative YAML to Programmatic Infrastructure: the building blocks this section assembles.

Core Concepts

Each topic below is a page of its own. Read them in roughly this order: stack and configuration decisions constrain how providers are wired, provider wiring constrains what a component can assume, and only then do automation and policy have a stable surface to sit on.

Reading order for the topics in this section Reading order for the topics in this section: layered from Stacks and configuration down to CrossGuard policy packs. Stacks and configuration boundaries and inputs come first Provider wiring AWS, Azure, GCP, Kubernetes credentials Component resources reusable typed subgraphs Automation API and dynamic providers programmatic drivers and CRUD gaps CrossGuard policy packs guardrails over a stable graph
Each layer assumes the one above it is settled: configuration constrains providers, providers constrain components, and policy only makes sense once the graph is stable.

Stack architecture and environment isolation

A Pulumi project is a directory with a Pulumi.yaml; a stack is one deployment of that project with its own configuration file (Pulumi.prod.yaml) and its own state. Pulumi stack architecture covers where to draw those boundaries: what belongs in a single stack, when a shared network layer should become its own stack consumed through pulumi.StackReference, and how pulumi.get_stack() should—and should not—be used to branch inside a program. The decision that matters most is blast radius. Two environments in one stack share a state file, a lock, and a failure; splitting them costs one extra config file and buys independent rollbacks.

Secrets and typed configuration

Configuration is the only sanctioned way to vary a program between stacks. Pulumi secrets and configuration covers pulumi.Config accessors (require, get_bool, require_object), structured values set with pulumi config set --path, and the encryption path: values stored with --secret are encrypted with the stack's secrets provider and stay marked as secret through every Output they flow into, including stack exports. The failure this prevents is the classic one—a database password interpolated into a user-data script and then printed in a preview diff. Configuration also has a namespace: a key set as pulumi config set region eu-west-1 becomes myproject:region, while pulumi config set aws:region eu-west-1 sets the provider's default region, and confusing the two produces resources in a region nobody asked for.

The AWS provider

The AWS provider deep dive is the reference for credential routing on the most common target: how the provider resolves credentials through the standard chain, how assume_role chains into a member account, why default_tags at the provider level beats tagging each resource by hand, and how to structure a program that writes to several accounts and regions in one update without losing track of which resource landed where.

The Azure provider

Azure provider configuration starts with the choice most teams get wrong at the beginning: pulumi_azure_native, generated directly from the Azure Resource Manager API specifications, versus the older pulumi_azure package bridged from Terraform. It then covers service principal and workload identity authentication, why subscription and location belong in stack configuration rather than ambient CLI state, and how resource groups behave as the parent of everything beneath them.

The GCP provider

GCP provider configuration covers project scoping, service account impersonation instead of downloaded key files, and the difference between a provider-level project/region default and a per-resource override. Google's eventual-consistency behaviour on IAM bindings is the recurring operational surprise, and the page shows the retry and dependency patterns that absorb it.

The Kubernetes provider

The Kubernetes provider with Python covers building a provider from a kubeconfig produced by another resource—so the Kubernetes cluster and its workloads can live in one program—along with await logic, Helm chart releases, and custom resource definitions. This is where an explicit provider instance stops being good practice and becomes mandatory: a Kubernetes resource without one targets whatever context the operator's laptop last selected.

Component resources

Pulumi component resources turn a subgraph—bucket plus versioning plus encryption plus access block—into one typed class with one parent URN. The page covers the constructor contract (super().__init__(t, name, props, opts)), why every child needs parent=self, what register_outputs actually does for the CLI's tree view, and how to publish a component so other teams consume a version rather than a copy.

The Automation API

The Pulumi Automation API removes the CLI from the loop: create_or_select_stack with an inline program, set_config, up(on_output=...), and typed results you can assert on. It is how self-service platforms, ephemeral preview environments, and integration tests that provision real infrastructure get built without shelling out and parsing text. The same backend, the same state, and the same providers are in play—the only thing that changes is who starts the update, which means every concurrency and locking rule from the CLI world still applies.

Dynamic providers and custom resources

Dynamic providers and custom resources fill the gaps: an internal REST API, a SaaS product with no plugin, a resource that must be created by calling a script. You implement create, read, update, delete, and diff in Python, and the engine treats the result as a first-class resource with state, diffs, and dependencies—at the cost of serialising your provider code into the state file.

Policy as code with CrossGuard

Pulumi policy as code runs a policy pack against the resource graph during preview and update. Rules are typed Python—ResourceValidationPolicy with an enforcement level of advisory or mandatory—so encryption, tagging, and network guardrails are enforced before the diff is applied rather than found by an auditor afterwards.

Architecture Decision Guide

The topics above each solve a problem, but they are not interchangeable, and picking the wrong one is expensive to reverse because the wrong choice is usually recorded in state. Four questions settle most designs: how many independent failure domains do you need, how do values cross between them, how much of the resource graph repeats, and who is allowed to start an update. Answer those and the rest of the layout follows.

Choosing a Pulumi structural pattern Choosing a Pulumi structural pattern: comparison across Reach for, State cost, Watch out for. Need Reach for State cost Watch out for Vary one program per environment Extra stack Separate checkpoint Config drift between stacks Share a VPC across teams StackReference Read-only cross-read Export names are an API Repeat a resource shape ComponentResource One parent URN Renaming children replaces them Target a second account Explicit Provider None Forgetting opts= on one resource Drive updates from code Automation API Same backend Concurrency on one stack name Cover an unsupported API Dynamic provider Code serialised Pickled closure upgrades
The structural choices are not interchangeable: each one buys a different property and charges a different price in state.

The table below is the short form. Read the "cost" column carefully — every one of these patterns trades a real property for another, and the trade is rarely symmetric.

Decision Option A Option B Pick A when Pick B when
Environment separation One project, one stack per environment A separate project per environment The resource shape is identical and only configuration differs Environments have genuinely different topologies or owners
Sharing a VPC or an EKS control plane pulumi.StackReference to a platform stack Duplicate the network in each stack The network changes rarely and is owned by one team Teams must be able to destroy everything they own without coordination
Repeating a resource shape A published ComponentResource package A copied module per repository Three or more consumers need the same fix rolled out The shape is still changing weekly and consumers want to fork
Targeting a second account or subscription An explicit provider instance passed through ResourceOptions Ambient AWS_PROFILE / az account set Anything beyond a single-account demo Never, in a shared repository
Starting an update The CLI inside a CI job The Automation API inside a service Changes arrive as pull requests Changes arrive as API calls from a portal or a test
An API no provider covers A dynamic provider with full CRUD A one-shot script outside Pulumi The object has a lifecycle worth diffing and deleting The action is genuinely fire-and-forget
Enforcing a standard A CrossGuard policy pack in the preview path Review checklists The rule is mechanically checkable The rule needs human judgement
State backend Pulumi Cloud Self-managed s3:// / azblob:// / gs:// You want locking, history and a secrets provider with no work Regulatory or network constraints keep state in your own account

Two rows deserve elaboration. The environment-separation row is where most teams over-correct: splitting dev, staging and prod into three projects duplicates the program and guarantees they drift, because a fix applied to one copy is a pull request nobody opens against the other two. One project with three stacks keeps a single program under test and moves the differences into Pulumi.dev.yaml, Pulumi.staging.yaml and Pulumi.prod.yaml, where a diff is readable. The exception is a genuine topology difference — a production environment with multi-region replicas and a development environment with a single instance are not the same program with different numbers in it, and forcing them together produces a thicket of if pulumi.get_stack() == "prod" branches that nothing tests.

The backend row matters because it silently decides your secrets story. pulumi login s3://acme-pulumi-state gives you no default secrets provider, so pulumi stack init falls back to a passphrase held in PULUMI_CONFIG_PASSPHRASE. That passphrase has to reach every engineer and every CI runner, and rotating it means re-encrypting every stack. Passing --secrets-provider="awskms://alias/pulumi?region=eu-west-1" at stack-init time instead moves the key into a service that already has rotation, audit and access control, and costs nothing extra to operate.

Canonical Code Pattern

Almost every well-behaved Pulumi Python program has the same skeleton: parse configuration into a typed object once, build the provider instances the program will use, pass those providers explicitly to everything, and export a small, deliberate set of outputs. The snippet below is that skeleton for a single AWS workload account. It is deliberately boring — the interesting parts are the four comments marking where state and provider identity are decided.

What happens during one update of the canonical pattern What happens during one update of the canonical pattern: Python program → Pulumi engine → AWS provider → State backend. Python program Pulumi engine AWS provider State backend RegisterResource read checkpoint Diff + Create resolved outputs Output resolves write checkpoint
The program and the engine exchange messages for the whole update; an Output only resolves once the provider has answered.
# CLI: pulumi up --stack prod --yes
from dataclasses import dataclass

import pulumi
import pulumi_aws as aws


@dataclass(frozen=True)
class PlatformSettings:
    """Every stack-varying input, resolved once, before any resource exists."""

    region: str
    workload_role_arn: str
    log_retention_days: int
    owner: str

    @staticmethod
    def load() -> "PlatformSettings":
        cfg = pulumi.Config()
        return PlatformSettings(
            region=cfg.require("region"),
            workload_role_arn=cfg.require("workloadRoleArn"),
            log_retention_days=cfg.require_int("logRetentionDays"),
            owner=cfg.require("owner"),
        )


settings = PlatformSettings.load()

# Provider note: this instance fixes the region AND the identity for everything below it.
# Without it, resources land wherever AWS_PROFILE happens to point on the runner.
workload = aws.Provider(
    "workload",
    region=settings.region,
    assume_role=aws.ProviderAssumeRoleArgs(
        role_arn=settings.workload_role_arn,
        session_name="pulumi-platform",
    ),
    default_tags=aws.ProviderDefaultTagsArgs(
        tags={"owner": settings.owner, "stack": pulumi.get_stack()},
    ),
)

child_opts = pulumi.ResourceOptions(provider=workload)

logs = aws.cloudwatch.LogGroup(
    "platform-logs",
    retention_in_days=settings.log_retention_days,
    opts=child_opts,
)

bucket = aws.s3.BucketV2("audit-logs", opts=child_opts)

# State implication: "audit-logs" is part of the URN. Renaming this first argument tells the
# engine the old bucket is gone and a new one should exist — a delete plus a create, not a rename.
aws.s3.BucketServerSideEncryptionConfigurationV2(
    "audit-logs-sse",
    bucket=bucket.id,
    rules=[
        aws.s3.BucketServerSideEncryptionConfigurationV2RuleArgs(
            apply_server_side_encryption_by_default=aws.s3.BucketServerSideEncryptionConfigurationV2RuleApplyServerSideEncryptionByDefaultArgs(
                sse_algorithm="aws:kms",
            ),
        )
    ],
    opts=child_opts,
)

# State implication: exports are the stack's public interface. Consumers read these by name
# through StackReference, so renaming one breaks them at preview time.
pulumi.export("log_group_arn", logs.arn)
pulumi.export("audit_bucket", bucket.bucket)

Four things in that program are load-bearing. PlatformSettings.load() runs before any resource is constructed, so a missing key fails in the first hundred milliseconds with a clear message rather than halfway through a partially-applied update. aws.Provider is constructed once and reused; constructing one per resource works but produces a state file full of provider objects and a preview full of noise. child_opts is passed to every resource — the moment one resource omits it, that resource silently uses the default provider, which is the single most common cause of a resource appearing in the wrong account. And default_tags on the provider means cost allocation and ownership tags cannot be forgotten on individual resources, which is a far more reliable mechanism than a code-review convention.

The pattern scales by adding provider instances rather than by adding branches. A program that writes to three accounts builds three aws.Provider objects from a list in configuration and threads the right ResourceOptions into the right subtree; a program that also writes to a Kubernetes API adds a kubernetes.Provider built from a kubeconfig that another resource produced. In both cases the shape stays the same: typed configuration in, explicit providers, explicit options, deliberate exports.

Architectural Principles of Pulumi Stacks

Stack contexts enforce strict environment isolation for development, staging, and production deployments. Remote backends provide encrypted state storage, version history, and distributed concurrency locking. Understanding the relationship between Pulumi Stack Architecture and deployment boundaries prevents state corruption during parallel executions. Pulumi Component Resources encapsulate complex topologies into typed, reusable units, while Dynamic Providers and Custom Resources bridge cloud APIs the standard providers do not yet cover.

Two boundaries deserve deliberate design. The first is the update boundary: everything inside one stack is deployed, rolled back, and locked together. A stack that contains both a shared VPC and forty application services means every application change waits on a lock held by whichever pipeline started first, and a failed network change blocks unrelated releases. The second is the reference boundary: values crossing stacks travel through StackReference, which reads the exported outputs of another stack's most recent checkpoint. Those outputs are a published interface. Renaming an export breaks every consumer at preview time with Missing required output 'vpcId' on stack 'acme/network/prod', so treat export names with the same care as a function signature.

Backend choice follows from those boundaries. The Pulumi Cloud backend gives per-stack locking, update history, and a hosted secrets provider without further work. A self-managed backend—pulumi login s3://acme-pulumi-state—keeps state inside your own account, but you own the locking story (DynamoDB or the backend's native conditional writes), the retention policy, and the secrets provider, which then must be set explicitly with pulumi stack init --secrets-provider="awskms://alias/pulumi" unless you want a passphrase every engineer has to share. Whatever you choose, the state bucket deserves versioning and access logging: it is the only artifact from which a stack can be reconstructed.

Architectural Principles of Pulumi Stacks Architectural Principles of Pulumi Stacks: layered from Architectural Principles down to Pulumi Component Resources. Architectural Principles Pulumi Stacks Stack Pulumi Stack Architecture Pulumi Component Resources
Architectural Principles of Pulumi Stacks: the building blocks this section assembles.

Provider Lifecycle & Configuration Strategies

Provider initialization requires deterministic credential resolution through environment variables or OIDC federation. Strict version pinning in requirements.txt prevents plugin drift and ensures reproducible deployments. Cross-provider orchestration relies on explicit dependency graphs rather than implicit resource ordering. For IAM role chaining and region-specific routing, consult the AWS Provider Deep Dive before scaling multi-account deployments. Service account delegation and project scoping follow similar patterns, detailed in GCP Provider Configuration.

A provider in Pulumi is a resource like any other, which surprises people the first time they see one in pulumi stack export. It has a URN, it has inputs, and changing an input the plugin treats as immutable — a region, an assume-role ARN — replaces the provider and, with it, every resource parented to it. That is why the region a provider uses belongs in stack configuration and never in a value computed from another resource: a provider whose inputs depend on an output cannot be created until that output resolves, and the engine will report error: provider ... is not yet available rather than silently ordering things for you.

Version pinning is the other half of provider lifecycle, and it has two layers that must agree. requirements.txt pins the Python SDK (pulumi-aws==6.66.2), and the SDK declares the plugin binary version it expects. Installing the SDK does not fetch the plugin on its own in every workflow, which is why a fresh CI runner can fail with error: no resource plugin 'aws' found in the workspace or on your $PATH. Running pulumi install inside the project directory resolves both layers from Pulumi.yaml and requirements.txt in one step, and pulumi plugin ls shows exactly which binaries the workspace has. Pin the SDK to an exact version rather than a range: a minor plugin bump can change a default the provider sends to the cloud API, and the first sign of that is an unexplained diff on a resource nobody touched.

Credential resolution deserves the same explicitness. On AWS, the provider walks the standard chain — explicit arguments, then environment variables, then the shared credentials file, then the instance or container role. In CI the right answer is almost always OIDC federation: the pipeline exchanges a signed workload token for short-lived credentials, so no long-lived access key exists to leak. The Pulumi side of that is nothing more than making sure the runner's ambient identity is the one you want, then layering assume_role on top for each target account. On Azure, the equivalent is a federated workload identity credential against an Entra ID application; on GCP it is workload identity federation with service-account impersonation, which the GCP provider configuration topic covers in full.

Provider Lifecycle & Configuration Strategies Provider Lifecycle & Configuration Strategies: requirements.txt then Provider Lifecycle then Configuration then Provider then AWS Provider Deep requirements.txt Provider Lifecycle Configuration Provider AWS Provider Deep
Provider Lifecycle & Configuration Strategies: the stages run left to right — requirements.txt, Provider Lifecycle, Configuration, Provider, AWS Provider Deep.

Pattern-Driven Infrastructure Design

The Component Resource pattern encapsulates networking logic into reusable, strongly-typed modules. Dependency injection via StackReference enables cross-stack configuration passing without hardcoding values. Monolithic stacks and implicit resource dependencies inevitably cause deployment bottlenecks and state drift. Encapsulated components enforce clear ownership boundaries and predictable update sequences. When you need to drive deployments from your own tooling rather than the CLI, the Pulumi Automation API embeds up, preview, and destroy directly in a Python process—the foundation for self-service platforms and integration tests.

Pattern Driven Infrastructure Design Pattern Driven Infrastructure Design: layered from StackReference down to Pulumi Automation API. StackReference preview destroy Component Resource Pulumi Automation API
Pattern Driven Infrastructure Design: the building blocks this section assembles.
# CLI Context: pulumi stack init networking; pulumi up
import pulumi
import pulumi_aws as aws
from typing import Optional

class VpcComponent(pulumi.ComponentResource):
    vpc_id: pulumi.Output[str]
    subnet_ids: pulumi.Output[list]

    def __init__(
        self,
        name: str,
        cidr_block: str,
        subnet_cidr_block: str = "10.0.1.0/24",
        opts: Optional[pulumi.ResourceOptions] = None,
    ) -> None:
        super().__init__("custom:networking:Vpc", name, {}, opts)

        vpc = aws.ec2.Vpc(
            f"{name}-vpc",
            cidr_block=cidr_block,
            opts=pulumi.ResourceOptions(parent=self),
        )
        subnet = aws.ec2.Subnet(
            f"{name}-subnet",
            vpc_id=vpc.id,
            cidr_block=subnet_cidr_block,
            opts=pulumi.ResourceOptions(parent=self),
        )

        self.vpc_id = vpc.id
        self.subnet_ids = pulumi.Output.all(subnet.id)
        self.register_outputs({"vpc_id": self.vpc_id, "subnet_ids": self.subnet_ids})

Development Workflow Integration

An infrastructure change should move through the same pipeline as an application change, and Pulumi makes that possible because every stage before deployment is an ordinary Python operation. The loop below is what a mature Pulumi Python repository actually runs, and only the last step needs cloud credentials with write access.

Where Pulumi fits in the change workflow Where Pulumi fits in the change workflow: Edit typed Python → mypy + ruff → pytest with Mocks → preview in CI → policy pack → up on merge → repeat. Edit typedPython mypy + ruff pytest withMocks preview in CI policy pack up on merge
Every step before 'up' runs without touching a cloud API, which is what makes an infrastructure change reviewable at the speed of an application change.

Type checking comes first and pays for itself immediately. The Pulumi Python SDKs ship inline type information, so mypy knows that aws.ec2.Instance has no instance_class argument and that retention_in_days is an int. Configuring mypy with disallow_untyped_defs on the infrastructure package turns a whole class of typo into a pre-commit failure rather than a failed update forty seconds into a deployment. ruff catches the rest — unused imports left behind after a refactor, a shadowed pulumi name, an f-string with no placeholders that was meant to interpolate an output.

Unit tests come next, and they run without a backend at all. pulumi.runtime.set_mocks() replaces the engine's resource-creation calls with a Python function you control, so a test constructs your component, receives synthetic identifiers, and asserts on the properties that were sent. This is where you test the things reviews miss: that every bucket in the module has encryption configured, that a production configuration produces multi-AZ database settings, that a component threads its provider option down to every child. These tests take milliseconds, so they belong on every commit.

Preview is the integration test. In CI, run pulumi preview --diff --non-interactive --stack staging on every pull request and post the output; it is the only stage that consults real state and real cloud APIs, and it is where a replace-instead-of-update shows up while it is still cheap to fix. Add --policy-pack ./policy and the same command evaluates your CrossGuard rules, so a pull request that introduces a public bucket fails the check rather than the audit. Reserve pulumi up --yes for merges to the default branch, gated behind an environment approval, and use --refresh deliberately rather than by default — a refresh rewrites state from the cloud, which is correct after an out-of-band change and wrong when someone has manually made a change you intend to revert.

# CLI: the three commands a Pulumi Python pull request should run, in order
mypy infra/ && ruff check infra/
pytest tests/ -q
pulumi preview --diff --non-interactive --stack staging --policy-pack ./policy

Two operational habits round this out. Keep pulumi stack export --stack prod > prod-state.json in a scheduled job so a corrupted checkpoint is a restore rather than an archaeology project, and treat pulumi about output as part of any bug report — it records the CLI version, the Python runtime, and every plugin version, which is usually enough to explain a diff that only appears on one engineer's machine.

Testing, Validation & CI/CD Integration

Infrastructure validation requires strict unit testing boundaries that isolate cloud provider APIs. pulumi.runtime.Mocks intercepts resource creation to verify property assignments and dependency graphs. Policy-as-Code enforcement via CrossGuard blocks non-compliant resource configurations before deployment. CI/CD pipelines must enforce automated pulumi preview gates and require manual approval for production apply operations.

Testing, Validation & CI/CD Integration Testing, Validation & CI/CD Integration: pulumi.runtime.Moc then apply then CD Integration pulumi.runtime.Moc apply CD Integration
Testing, Validation & CI/CD Integration: the stages run left to right — pulumi.runtime.Moc, apply, CD Integration.
# CLI Context: pytest tests/test_infra.py -v
import pulumi
import pulumi.runtime
import pytest
from typing import Any, Dict, Tuple
from my_vpc_module import VpcComponent

class MockProvider(pulumi.runtime.Mocks):
    def new_resource(self, args: pulumi.runtime.MockResourceArgs) -> Tuple[str, Dict[str, Any]]:
        return (
            f"{args.name}-mock-id",
            {"arn": f"arn:aws:ec2:us-east-1:123456789012:{args.type}/{args.name}"},
        )

    def call(self, args: pulumi.runtime.MockCallArgs) -> Dict[str, Any]:
        return {}

@pytest.fixture(autouse=True)
def set_mocks():
    pulumi.runtime.set_mocks(MockProvider(), preview=False)

@pytest.mark.asyncio
async def test_vpc_component_outputs() -> None:
    """Assert VPC component registers outputs after resource creation."""
    vpc = VpcComponent("test-vpc", cidr_block="10.0.0.0/16")

    vpc_id = await pulumi.Output.from_input(vpc.vpc_id).future()
    assert vpc_id is not None
    assert "mock-id" in vpc_id

Common Failure Modes

The failures below account for most of the time lost on Pulumi Python projects. None of them are subtle once you have seen them; all of them are invisible until you have.

Symptom to root cause on a failed Pulumi update Symptom to root cause on a failed Pulumi update: comparison across Root cause, First move. Symptom Root cause First move Resource created in the wrong account No provider in ResourceOptions Grep for opts= on every resource Preview shows replace on an untouched resource Logical name or parent changed Add an alias, then rename Literal 'Calling __str__' in a tag value Output interpolated as a string Use Output.concat or apply 'no resource plugin' on a clean checkout Plugin not installed for the SDK Pin the SDK, reinstall plugins 'the stack is currently locked' Interrupted update left a lock Confirm no run, then cancel
Most failed updates map to one of five causes; the symptom column is what you actually see in the terminal, not what went wrong.

A resource lands in the wrong account or region. Nothing errors. The only signal is that a resource you expected in the workload account is sitting in the management account, or a bucket you expected in eu-west-1 appears in us-east-1. The cause is a resource constructed without opts=pulumi.ResourceOptions(provider=...), which makes the engine resolve the default provider for that package from aws:region configuration and ambient credentials. The fix is mechanical: make the explicit provider mandatory by never letting a resource be constructed outside a component that takes a provider in its constructor, and add a CrossGuard rule that fails any resource whose provider URN is not one of the ones you built.

A rename becomes a replacement. Preview shows -+ aws:s3/bucketV2:BucketV2: (replace) with [diff: ~urn] on a resource whose arguments you did not touch. The first constructor argument — the logical name — changed, or a resource acquired a parent it did not have before, and either of those changes the URN. Pulumi has no way to know the new URN refers to the same real object. The correct move is to add opts=pulumi.ResourceOptions(aliases=[pulumi.Alias(name="old-name")]), run pulumi up so the state file records the new URN against the existing object, and remove the alias in a later commit once every stack has been updated. Doing the rename without an alias on a database or a bucket with a retention policy will fail loudly at delete time — which is the lucky outcome.

An output ends up in a string. A tag reads Calling __str__ on an Output[T] is not supported. followed by the SDK's suggestion text, or a Kubernetes ConfigMap contains the same paragraph where a hostname should be. Python cannot raise inside __str__, so the wrapper stringifies to a diagnostic instead. Anywhere you were about to write f"https://{bucket.bucket_regional_domain_name}", write pulumi.Output.concat("https://", bucket.bucket_regional_domain_name); anywhere you need real logic, use .apply() and return the finished value from the callback.

A required configuration value is missing. The update stops with error: Missing required configuration variable 'platform:region' and a hint to run pulumi config set platform:region <value>. This is the failure you want, and the reason to parse configuration into a typed object at the top of the program rather than calling pulumi.Config().require() deep inside a helper: failing before any resource is constructed means nothing was half-created. The corresponding trap is cfg.get("region"), which returns None instead of failing and pushes the error into a provider argument, where it surfaces much later as an unhelpful API validation message.

A plugin is missing on a clean checkout. error: no resource plugin 'aws' found in the workspace or on your $PATH means the Python SDK is installed but the provider binary is not. It shows up on new CI runners and on a colleague's laptop, never on the machine where the project was written. Run pulumi install in the project directory, keep the SDK pinned to an exact version in requirements.txt, and cache the plugin directory between CI jobs so this is a one-time cost rather than a per-build download.

A stack is locked. Against Pulumi Cloud the message is error: [409] Conflict: Another update is currently in progress.; against a self-managed backend it is error: the stack is currently locked by 1 lock(s) with the path of the lock object. Either a run is genuinely in flight or a previous one was killed before it could release. Confirm nothing is running — check the pipeline, not just your own terminal — then pulumi cancel --stack prod. Never delete the lock object by hand while an update might still be writing: two concurrent updates against one checkpoint is how a state file starts describing resources that no longer exist.

A secret is stored in plaintext. pulumi config set dbPassword hunter2 writes the literal value into Pulumi.prod.yaml, and it is in the repository the moment anyone commits. The same command with --secret encrypts it with the stack's secrets provider and keeps the value marked as secret through every Output it flows into, so pulumi stack output prints [secret] unless explicitly asked otherwise. There is no retroactive fix beyond rotating the credential — an encrypted value in the current commit is still plaintext in the history.

Migration Pathways for Python Developers

Existing Terraform HCL modules translate to Python using the pulumi convert --language python utility. Virtual environments and pip-tools guarantee deterministic dependency resolution across engineering workstations. Structured logging and pulumi preview --diff expose resource drift before state mutations occur. Configuration-driven instantiation enables environment-aware defaults and conditional resource provisioning.

The realistic migration is not a rewrite. pulumi convert --from terraform --language python --out ./migrated reads HCL and emits a Python program, but it converts syntax, not judgement: the output is a flat list of resources with the module structure flattened out, and turning that into components is manual work. Treat the converted program as a starting draft that proves the resource arguments were understood, then restructure it into the typed shape shown earlier.

Adopting existing infrastructure is the step that actually matters, and it is separate from conversion. pulumi import aws:s3/bucketV2:BucketV2 audit-logs acme-audit-logs reads the live resource, writes it into the stack's state, and prints the Python code that matches what it found. Paste that code into your program, run pulumi preview, and iterate until the preview is empty — a non-empty preview means your program and the real resource disagree, and applying it would change production. For resources you want under Pulumi's management but never destroyed, add opts=pulumi.ResourceOptions(protect=True) so an accidental pulumi destroy fails with error: unable to delete resource ... as it is currently marked for protection.

Run the two systems side by side while you migrate. Terraform keeps managing what it already owns; Pulumi manages new work and whatever has been imported, and the boundary between them travels through data sources — aws.ec2.get_vpc(id=...) reads a VPC that Terraform still owns without claiming it. The cutover is complete when the Terraform state file is empty, not when the last HCL file is deleted.

Migration Pathways for Python Developers Migration Pathways for Python Developers: Migration Pathways then Python Developers then Existing then Python Migration Pathways Python Developers Existing Python
Migration Pathways for Python Developers: the stages run left to right — Migration Pathways, Python Developers, Existing, Python.
# CLI Context: pulumi config set environment dev; pulumi up
import pulumi
import pulumi_aws as aws
from typing import Dict

def create_environment_resources(env: str) -> Dict[str, pulumi.Output[str]]:
    cfg = pulumi.Config()
    instance_type = cfg.require("instance_type")

    if env == "production":
        instance = aws.ec2.Instance(
            "prod-web",
            instance_type=instance_type,
            ami="ami-0c55b159cbfafe1f0",
        )
    else:
        instance = aws.ec2.Instance(
            "dev-web",
            instance_type=instance_type,
            ami="ami-0c55b159cbfafe1f0",
        )

    return {"instance": instance.id}

env_context = pulumi.Config().require("environment")
resources = create_environment_resources(env_context)
pulumi.export("web_instance_id", resources["instance"])

Key Takeaways

Pulumi's value proposition for Python engineers is that infrastructure code is just Python—tested with pytest, linted with mypy, packaged with pip. The patterns in this section (Component Resources, StackReference, Mocks-based testing) are the building blocks for production-grade Pulumi projects. Master them before scaling to multi-account or multi-region architectures.

FAQ

Where should I start with Pulumi?

With stack architecture and provider configuration, then components and testing; the AWS provider deep dive grounds it in concrete services.

How does Pulumi manage state?

Through a backend (Pulumi Cloud, S3, or similar) that records the mapping from your program to real resources; see managing IaC state. A self-managed backend such as pulumi login s3://acme-pulumi-state keeps every checkpoint inside your own account, but you then own locking, retention and the secrets provider — which defaults to a shared passphrase unless you pass --secrets-provider="awskms://alias/pulumi" when the stack is created. Enable bucket versioning either way; the checkpoint is the only artifact a stack can be rebuilt from.

Can I enforce standards across Pulumi stacks?

Yes — policy as code with CrossGuard runs organisational rules on every preview and blocks non-compliant changes. Point pulumi preview --policy-pack ./policy at the pack in CI so the rules run on every pull request, and set enforcement_level to advisory for a release or two before switching a new rule to mandatory.

Why does my preview want to replace a resource I did not change?

Almost always because its URN changed, not its arguments. Renaming the first constructor argument, giving a resource a new parent, or moving it inside a ComponentResource all produce a new URN, and the engine reads that as "the old resource is gone, create a new one". Add pulumi.ResourceOptions(aliases=[pulumi.Alias(name="old-name")]), apply once so state records the mapping, then drop the alias.

How do I run Pulumi against two AWS accounts in one program?

Build one aws.Provider per account, each with its own assume_role, and pass the right one through opts=pulumi.ResourceOptions(provider=...) on every resource in that account's subtree. Never rely on AWS_PROFILE to switch — it is ambient, it is not recorded in state, and a single resource that forgets the option lands in whichever account the runner defaults to. Managing multi-account AWS environments with Pulumi Python works through the full layout.

Should infrastructure tests use mocks or real deployments?

Both, at different frequencies. pulumi.runtime.Mocks tests run on every commit in milliseconds and assert on the graph your program builds — encryption set, tags present, provider threaded through. Real deployments belong in a nightly job driven by the Automation API, which creates an ephemeral stack, asserts against live endpoints, and destroys it in a finally block.