AWS Provider Deep Dive
Infrastructure as Code demands deterministic provider initialization. The pulumi-aws v6+ provider enforces strict schema validation, and Python 3.9+ typing eliminates runtime ambiguity. This page is the AWS-focused part of the broader Pulumi Patterns & Provider Management workflow, and it anchors the concrete AWS service guides below.
What follows is the provider layer itself rather than any one service: how an aws.Provider object is constructed and where it is recorded, how credentials are resolved at each layer beneath your Python, how state and locking actually behave on each backend, and how a single program addresses several accounts and regions without cross-wiring them. From here, work through the deeper guides: managing multi-account AWS environments with Pulumi Python for STS role chaining across an organisation, and securing Pulumi secrets with AWS KMS and HashiCorp Vault for envelope-encrypted state. Then take the service walk-throughs in whatever order your platform needs them: deploying an EKS cluster with Pulumi Python covers managed Kubernetes with node groups and IRSA, provisioning RDS PostgreSQL with Pulumi Python covers subnet groups and encrypted credentials, provisioning DynamoDB tables with Pulumi Python covers capacity modes and secondary indexes, and deploying AWS Lambda functions with Pulumi Python covers packaging, log groups, and triggers.
Problem Framing
A Pulumi Python program that touches AWS looks like a single layer of code and behaves like five. aws.ec2.Vpc("app-vpc", cidr_block="10.0.0.0/16") does not call EC2. It registers a resource with the Pulumi engine, which resolves the inputs, diffs them against the checkpoint, and hands the operation to the pulumi-aws plugin — a separate process speaking gRPC, built by bridging the Terraform AWS provider. That plugin uses the AWS SDK for Go v2, which resolves credentials, signs the request, and applies its own retry and back-off policy before EC2 ever sees a CreateVpc call.
Almost every confusing AWS failure in Pulumi is a failure at one of those layers being reported at another. A mypy-clean program fails at pulumi preview with a credential error because the plugin, not Python, resolves credentials. A resource is created in us-east-1 when the stack config says eu-west-1 because AWS_REGION was set in the CI runner and the program never declared an explicit provider. Two engineers deploy simultaneously and one gets a lock error whose wording depends entirely on which backend the project is logged into. None of these are bugs in your resource definitions.
The second recurring problem is scope. Teams start with one account, so they never instantiate a provider at all — Pulumi builds an implicit default provider from the aws: configuration namespace and everything works. Then a second account appears, a sts:AssumeRole is bolted on through an environment variable, and now the same program creates resources in whichever account the ambient credentials happen to point at. There is no error, because from the provider's point of view the request was perfectly valid. The fix is structural: make providers explicit objects, bind every resource to one, and let the type checker enforce that binding.
The third is state. Pulumi's checkpoint records not only your resources but the provider each one was created through, as a pulumi:providers:aws resource with its own URN and configuration. Changing provider configuration therefore changes state, and unpicking "which provider created this resource" after the fact is far harder than getting it right on the first pulumi up.
Prerequisites
- Python 3.9+ with
pulumi>=3.100andpulumi-aws>=6.0installed in the project virtualenv. Pin both inrequirements.txtorpyproject.toml; a floatingpulumi-awswill silently pull a new plugin binary on the nextpulumi up. - The Pulumi CLI logged into a backend — Pulumi Cloud (
pulumi login), a self-managed bucket (pulumi login s3://my-state-bucket), or the local filesystem (pulumi login --local). This choice determines your locking semantics, so make it before the first deployment. - An AWS identity that can already call
sts:GetCallerIdentityfrom your shell, plus the deployment role you intend Pulumi to assume. Confirm both work with the AWS CLI first; debugging IAM through a Pulumi error message is slower than debugging it directly. mypyandpytestavailable, withmoto>=5.0if you plan to follow the testing section.moto5 replaced the per-service decorators with a singlemock_aws.- Ambient credential handling as described in best practices for managing cloud credentials in Python — this page assumes no access key is ever written to disk inside the project.
# CLI: confirm the toolchain and the identity before writing any resource code
pulumi version
pulumi about --json | python3 -c "import json,sys; print(json.load(sys.stdin)['plugins'])"
aws sts get-caller-identity --query 'Arn' --output text
Provider Initialization & Python 3.9+ Typing
Define explicit configuration schemas using typing.TypedDict and dataclasses. Strong typing catches misconfigurations before execution and eliminates silent drift during pulumi preview.
Embed foundational lifecycle hooks early. Refer to Pulumi Patterns & Provider Management for baseline initialization workflows.
CLI: Run
pulumi stack initbefore binding typed configurations to enforce environment isolation.
from __future__ import annotations
import pulumi
import pulumi_aws as aws
from typing import TypedDict, Optional
from dataclasses import dataclass
class ProviderConfig(TypedDict, total=False):
region: str
profile: Optional[str]
skip_credentials_validation: bool
@dataclass(frozen=True)
class AwsProviderSpec:
region: str
profile: Optional[str] = None
skip_validation: bool = False
def initialize_aws_provider(spec: AwsProviderSpec) -> aws.Provider:
return aws.Provider(
"primary",
region=spec.region,
profile=spec.profile,
skip_credentials_validation=spec.skip_validation,
)
The default provider, and why it hides bugs
If you never construct an aws.Provider, Pulumi builds one for you. On the first AWS resource registration the engine creates an implicit provider from every key in the aws: configuration namespace — aws:region, aws:profile, aws:assumeRole and the rest — and gives it a generated name. You can see it in state:
# CLI: list the provider resources the engine actually created
pulumi stack --show-urns | grep 'pulumi:providers:aws'
# urn:pulumi:prod::platform::pulumi:providers:aws::default_6_66_2
The default_6_66_2 suffix is the plugin version, which means a provider upgrade changes the default provider's URN and every resource's recorded provider reference along with it. That is harmless in a single-account stack and deeply confusing in a stack that also has explicit providers, because half the resources point at default_6_66_2 and half at a named provider whose configuration may disagree. Reading a diff in that state is guesswork.
Declaring providers explicitly costs one object and removes the ambiguity permanently. The AwsProviderSpec dataclass above is the minimum; a production spec usually also carries the organisational guardrails that belong on the provider rather than on individual resources.
# providers.py — a provider that carries the organisational guardrails
# CLI: pulumi preview --stack prod
from __future__ import annotations
import pulumi
import pulumi_aws as aws
def platform_provider(name: str, region: str, account_id: str) -> aws.Provider:
return aws.Provider(
name,
region=region,
# Provider note: refuses to act if ambient credentials resolve to another account
allowed_account_ids=[account_id],
# Provider note: merged into every resource's tags; a resource-level tag of the
# same key overrides this value rather than conflicting with it
default_tags=aws.ProviderDefaultTagsArgs(
tags={
"ManagedBy": "pulumi",
"Project": pulumi.get_project(),
"Stack": pulumi.get_stack(),
}
),
# Provider note: stops EKS- and autoscaling-managed tags producing a perpetual diff
ignore_tags=aws.ProviderIgnoreTagsArgs(
key_prefixes=["kubernetes.io/", "k8s.io/"],
),
max_retries=10,
retry_mode="adaptive",
# State implication: pins the plugin binary so a `pip install -U` cannot change
# the provider URN underneath an existing stack
opts=pulumi.ResourceOptions(version="6.66.2"),
)
Three of those arguments repay their length immediately. allowed_account_ids converts the worst failure mode on this page — deploying into the wrong account because ambient credentials pointed somewhere unexpected — from a silent success into error: AWS account ID not allowed: 111122223333 before a single resource is created. default_tags gives you cost allocation and ownership without threading a tags dictionary through every function signature. ignore_tags is the cure for the diff that reappears on every preview after an EKS cluster or an autoscaling group starts tagging your subnets.
Binding a resource to a provider is one option; forgetting to bind it is the common error, and it is invisible because the resource still deploys, just through the default provider. Two rules make the mistake impossible to write by accident: pass opts=pulumi.ResourceOptions(provider=...) on every leaf resource, and pass providers=[...] — plural — on every ComponentResource, because a component does not create resources itself and must hand the provider down to its children. The Pulumi component resources topic covers that inheritance in detail.
Credential Routing & Security Boundaries
Route credentials through explicit IAM role assumption. Never inject static access keys into source control. Use assume_role or OIDC-based assume_role_with_web_identity for dynamic STS token generation.
Enforce least-privilege boundaries via environment variables. Rotate secrets through Pulumi config encryption. For advanced KMS-backed state encryption and external secret backends, see Securing Pulumi secrets with AWS KMS and HashiCorp Vault.
CLI: Execute
pulumi config set --secret aws:profile <profile-name>to bind credentials securely.
The resolution order you are actually relying on
The plugin resolves credentials through the AWS SDK for Go v2 default chain, and it stops at the first source that yields a usable set. In order: static access_key/secret_key/token arguments on the provider; the AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY and AWS_SESSION_TOKEN environment variables; the shared credentials and config files selected by AWS_PROFILE, AWS_SHARED_CREDENTIALS_FILE and AWS_CONFIG_FILE; container credentials from AWS_CONTAINER_CREDENTIALS_RELATIVE_URI or AWS_CONTAINER_CREDENTIALS_FULL_URI; the web identity token named by AWS_WEB_IDENTITY_TOKEN_FILE, which is how IRSA works on EKS; and finally the EC2 instance metadata service.
Two consequences follow. First, an environment variable beats a profile — an AWS_ACCESS_KEY_ID left over from an earlier shell command silently outranks the profile="platform-prod" you carefully set on the provider, because static provider arguments are the only thing that outranks the environment. Second, the chain is evaluated inside the plugin process, so boto3 in the same program can resolve to a different identity than the resource graph does if you construct a boto3.Session with an explicit profile. When those two disagree, the symptom is a data source that reads from one account while resources are created in another.
assume_role sits on top of whatever the chain produced. The base credentials authenticate the sts:AssumeRole call; the returned temporary credentials sign every subsequent API call. That two-step is why an assume-role failure reports the base identity in the error rather than the role you asked for.
# roles.py — the three credential shapes worth knowing, all typed
# CLI: pulumi up --stack prod
from __future__ import annotations
import pulumi
import pulumi_aws as aws
# 1. Cross-account chaining from a bootstrap identity. external_id is mandatory
# whenever a third party can attempt the same assume-role call.
cross_account = aws.Provider(
"workload-prod",
region="eu-west-1",
assume_role=aws.ProviderAssumeRoleArgs(
role_arn="arn:aws:iam::444455556666:role/PlatformDeploy",
session_name="pulumi-platform-prod",
external_id="c7f0a2d1-platform",
duration="1h",
),
)
# 2. GitHub Actions OIDC — no long-lived secret exists anywhere in CI.
# Provider note: the token file is written by the CI runner, not by Pulumi.
federated = aws.Provider(
"ci",
region="eu-west-1",
assume_role_with_web_identity=aws.ProviderAssumeRoleWithWebIdentityArgs(
role_arn="arn:aws:iam::444455556666:role/GitHubActionsPulumi",
session_name="gha-pulumi",
web_identity_token_file="/tmp/aws-web-identity-token",
),
)
# 3. Read back who the engine believes it is, and export it for auditability.
identity = aws.get_caller_identity_output(opts=pulumi.InvokeOptions(provider=cross_account))
pulumi.export("deployAccountId", identity.account_id)
pulumi.export("deployRoleArn", identity.arn)
Exporting deployAccountId is a cheap and durable control. A pull request that changes it is a pull request that changes which AWS account the stack targets, and that is exactly the change that should never pass review unnoticed.
Session duration deserves a note. The default is one hour, and the maximum is capped by the role's MaxSessionDuration, not by what you request — asking for duration="4h" against a role whose maximum is one hour fails with api error ValidationError: The requested DurationSeconds exceeds the MaximumSessionDuration set for this role. Long-running updates that create an EKS cluster or an RDS instance can outlive a short session, and the plugin does refresh credentials, but only if the base credentials are still valid. A CI job holding a fifteen-minute OIDC token cannot refresh anything.
State Management & Stack Isolation
Pulumi's state model is not Terraform's, and importing Terraform habits here is the single most common source of wasted afternoons. There is no DynamoDB lock table. Pulumi Cloud serialises updates server-side; the self-managed S3 backend writes lock objects into the same bucket, under .pulumi/locks/, and only when PULUMI_SELF_MANAGED_STATE_LOCKING=1 is set. Enable S3 versioning regardless — it is the only way to recover a checkpoint you have just overwritten.
Partition state using separate Pulumi stacks and explicit provider instances per environment. This isolates dependency graphs across environments. Review Pulumi Stack Architecture for advanced partitioning strategies.
CLI: Run
pulumi preview --diffto surface configuration drift before applying changes.
from __future__ import annotations
import pulumi.automation as auto
from typing import Final
BACKEND_BUCKET: Final[str] = "infra-state-bucket"
LOCK_TABLE: Final[str] = "pulumi-state-lock"
def bootstrap_state_backend(project_name: str, stack_name: str) -> auto.Stack:
"""Create or select a stack configured for the S3/DynamoDB backend.
Note: The backend URL is set at project level (pulumi login), not per-stack config.
This function configures the AWS region for stack resources.
"""
stack = auto.create_or_select_stack(
stack_name=stack_name,
project_name=project_name,
program=lambda: None,
)
stack.set_config("aws:region", auto.ConfigValue(value="us-east-1"))
return stack
# CLI: pulumi login s3://infra-state-bucket
Bucket layout and locking in practice
The self-managed backend stores one checkpoint per stack at s3://<bucket>/.pulumi/stacks/<project>/<stack>.json, plus a rolling history under .pulumi/history/. Three bucket settings are non-negotiable: versioning on, so an overwritten checkpoint is recoverable; default encryption with SSE-KMS, because the checkpoint contains every resource input including ones the provider does not consider secret; and a bucket policy that denies s3:DeleteObject to everything except a break-glass role.
# CLI: prepare a self-managed state bucket, then log into it
aws s3api create-bucket --bucket infra-state-bucket --region eu-west-1 \
--create-bucket-configuration LocationConstraint=eu-west-1
aws s3api put-bucket-versioning --bucket infra-state-bucket \
--versioning-configuration Status=Enabled
aws s3api put-public-access-block --bucket infra-state-bucket \
--public-access-block-configuration BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true
export PULUMI_SELF_MANAGED_STATE_LOCKING=1
pulumi login s3://infra-state-bucket
pulumi stack init prod --secrets-provider="awskms://alias/pulumi-prod?region=eu-west-1"
With locking enabled, a second concurrent update fails fast with error: the stack is currently locked by 1 lock(s). Either wait for the other process(es) to end or manually delete the lock file(s), followed by the object key and the user and process that created it. Without it, the second update simply wins and the first update's resources become orphans in AWS with no record in the checkpoint — the failure mode worth setting one environment variable to avoid. On Pulumi Cloud the equivalent message is error: [409] Conflict: Another update is currently in progress, and pulumi cancel is how you clear a lock left behind by a killed CI job.
The choice between backends is a real trade-off rather than an obvious one, and it is argued in full in choosing a state backend for Python IaC. What matters here is that the backend is a per-project decision recorded outside your code, while the secrets provider is a per-stack decision recorded inside Pulumi.<stack>.yaml — see Pulumi secrets and configuration for how the two interact.
Multi-Account & Multi-Region Routing Patterns
Architect provider aliasing for deterministic cross-region provisioning. Instantiate isolated provider instances per account. Bind each resource explicitly to its target provider.
Validate organizational SCP compliance during pulumi up. Enforce mandatory tagging policies at the provider level using default_tags. For detailed routing matrices, consult Managing multi-account AWS environments with Pulumi Python.
CLI: Use
pulumi stack --show-urnsto verify provider-to-resource binding before deployment.
from __future__ import annotations
import pulumi
import pulumi_aws as aws
from typing import Sequence, Mapping, Final
AccountRoute = Mapping[str, str]
REGIONS: Final[Sequence[str]] = ["us-east-1", "eu-west-1"]
def provision_regional_providers(
accounts: Sequence[AccountRoute],
) -> dict[str, aws.Provider]:
providers: dict[str, aws.Provider] = {}
for account in accounts:
region = account.get("region", "us-east-1")
providers[region] = aws.Provider(
f"provider-{region}",
region=region,
assume_role=aws.ProviderAssumeRoleArgs(
role_arn=account["role_arn"],
session_name=f"pulumi-session-{region}",
),
)
return providers
def deploy_vpc(
region: str,
provider_map: dict[str, aws.Provider],
) -> aws.ec2.Vpc:
target_provider = provider_map[region]
return aws.ec2.Vpc(
f"vpc-{region}",
cidr_block="10.0.0.0/16",
enable_dns_hostnames=True,
opts=pulumi.ResourceOptions(provider=target_provider),
)
Where multi-region programs actually break
Three details separate a working multi-region program from one that mostly works.
Global services have a home region. An ACM certificate attached to a CloudFront distribution must live in us-east-1 no matter where the rest of the stack lives, and so must a WAFv2 web ACL scoped to CLOUDFRONT. That means even a single-region stack usually needs a second provider. Name it for its purpose — aws.Provider("global-us-east-1", region="us-east-1") — rather than for the region, so the next reader understands why it exists.
Provider identity is part of a resource's identity. If you rename a provider, or change its configuration in a way that forces replacement, every resource bound to it shows a diff on its provider reference. Pulumi will happily execute that as a delete-and-recreate on resources you did not intend to touch. Run pulumi preview --diff and read the provider lines, not just the resource lines, whenever provider configuration changes.
Each provider instance is a live plugin connection. Building providers in a loop over thirty accounts starts thirty credential resolutions and thirty sets of retry state. That works, but it also means one expired role in the list fails the whole preview. Construct providers lazily for the accounts a given stack actually touches, and split the rest into separate stacks connected by StackReference — the approach laid out in Pulumi stack architecture.
# routing.py — per-account routing with a global provider for us-east-1 services
# CLI: pulumi up --stack prod --diff
from __future__ import annotations
import pulumi
import pulumi_aws as aws
WORKLOAD_REGION = "eu-west-1"
workload = aws.Provider(
"workload",
region=WORKLOAD_REGION,
allowed_account_ids=["444455556666"],
)
# Provider note: ACM certificates for CloudFront are only readable in us-east-1
global_edge = aws.Provider(
"global-edge",
region="us-east-1",
allowed_account_ids=["444455556666"],
)
edge_cert = aws.acm.Certificate(
"edge-cert",
domain_name="app.example.internal",
validation_method="DNS",
opts=pulumi.ResourceOptions(provider=global_edge),
)
bucket = aws.s3.BucketV2(
"assets",
opts=pulumi.ResourceOptions(provider=workload),
)
pulumi.export("certArn", edge_cert.arn)
pulumi.export("assetsBucket", bucket.bucket)
Classic and Native: Choosing the AWS Package
There are two first-party AWS packages for Pulumi, and knowing which one you are importing changes what a resource argument means. pulumi_aws — often called the classic provider — is generated by bridging the Terraform AWS provider, so its resource names, argument names and diff behaviour inherit Terraform's model. pulumi_aws_native is generated from the AWS Cloud Control API schemas, so its resources match CloudFormation property names and appear as soon as AWS publishes a Cloud Control type.
In practice the decision is straightforward. Use pulumi_aws for everything, because its coverage is effectively complete and its import and drift behaviour are well understood. Reach for pulumi_aws_native only when the bridged provider has not yet shipped a resource you need, and isolate that usage behind a component so switching back later is a one-file change. Mixing the two in one program is supported — they are separate plugins with separate providers — but a resource created by one cannot be updated by the other without an import and a state edit, so the boundary should follow a service, never a single resource.
One argument that trips people up on migration: in pulumi_aws, S3 buckets are modelled as aws.s3.BucketV2 plus separate resources for versioning, encryption, ownership and public access, mirroring the split the Terraform provider adopted. The older single-resource aws.s3.Bucket still exists and is deprecated. pulumi_aws_native models the bucket as one object with nested properties. Reading a code sample without knowing which package it came from is how a stack ends up with two competing definitions of the same bucket.
Testing Boundaries & CI/CD Integration
Structure unit tests with pytest and moto to intercept boto3 calls. Mocking eliminates live AWS dependencies and accelerates CI feedback loops.
Implement integration tests using pulumi.automation with ephemeral stack teardown. Configure GitHub Actions with OIDC federation for credentialless plan/apply gates. Cross-cloud mocking strategies differ; review GCP Provider Configuration for comparative testing frameworks.
CLI: Run
pytest -v --tb=shortto validate mocked resource parameters before merging IaC changes.
from __future__ import annotations
import pytest
import boto3
from moto import mock_aws
from typing import Generator
@pytest.fixture
def aws_credentials(monkeypatch: pytest.MonkeyPatch) -> None:
"""Set fake AWS credentials for moto."""
monkeypatch.setenv("AWS_ACCESS_KEY_ID", "testing")
monkeypatch.setenv("AWS_SECRET_ACCESS_KEY", "testing")
monkeypatch.setenv("AWS_DEFAULT_REGION", "us-east-1")
@pytest.fixture
def ec2_client(aws_credentials) -> Generator[boto3.client, None, None]:
with mock_aws():
yield boto3.client("ec2", region_name="us-east-1")
def test_vpc_creation_parameters(ec2_client) -> None:
"""Validate VPC creation without hitting real AWS endpoints."""
response = ec2_client.create_vpc(CidrBlock="172.16.0.0/16")
vpc = response["Vpc"]
assert vpc["CidrBlock"] == "172.16.0.0/16"
assert vpc["State"] in ("pending", "available")
moto intercepts boto3, which is the right tool for helper code that calls AWS directly — the pattern is developed further in mocking AWS services with moto in pytest. It does not intercept the pulumi-aws plugin, because that plugin is a separate Go process using its own SDK. To test resource definitions you need Pulumi's own mock layer, which replaces the engine rather than the network.
# test_providers.py — assert provider binding without any AWS call at all
# CLI: pytest -q test_providers.py
from __future__ import annotations
from typing import Any
import pulumi
class ProviderMocks(pulumi.runtime.Mocks):
def new_resource(self, args: pulumi.runtime.MockResourceArgs) -> tuple[str, dict[str, Any]]:
# State implication: mocked IDs never reach a checkpoint; nothing is persisted
return f"{args.name}_id", dict(args.inputs)
def call(self, args: pulumi.runtime.MockCallArgs) -> dict[str, Any]:
if args.token == "aws:index/getCallerIdentity:getCallerIdentity":
return {"accountId": "444455556666", "arn": "arn:aws:iam::444455556666:role/test"}
return {}
pulumi.runtime.set_mocks(ProviderMocks(), project="platform", stack="test", preview=False)
import infra # noqa: E402 — imported after set_mocks so registrations are intercepted
@pulumi.runtime.test
def test_every_bucket_is_tagged() -> None:
def check(tags: dict[str, str] | None) -> None:
assert tags is not None and tags["ManagedBy"] == "pulumi"
return infra.assets_bucket.tags.apply(check)
Two rules keep this suite honest. Import the module under test after set_mocks, or the first resource registration escapes the mock and the test hangs waiting for an engine that is not there. And assert on inputs you actually control — mocked outputs are whatever your new_resource returns, so an assertion about an ARN format is testing your mock, not your program. The broader technique is covered in unit testing Pulumi programs with mocks.
For CI, the shape that has held up best is: pulumi preview --diff --non-interactive on every pull request against a long-lived preview stack, mypy and pytest as separate jobs that need no AWS credentials at all, and pulumi up gated behind a protected environment. Credentials come from an OIDC-federated role with permissions: id-token: write in the workflow, so no access key exists in the repository or the runner. Policy enforcement belongs in the same gate — see Pulumi policy as code for CrossGuard policies that reject a preview outright.
Step-by-Step: A Production-Shaped AWS Baseline
1. Create the deployment role before writing any Pulumi code
The role Pulumi assumes should exist independently of Pulumi, created once by an administrator or a separate bootstrap stack. Its trust policy names exactly the principals allowed to assume it, and its permissions policy is scoped to the services this stack manages. Verify it from your own shell before Pulumi is involved.
# CLI: prove the role is assumable before pointing Pulumi at it
aws sts assume-role \
--role-arn arn:aws:iam::444455556666:role/PlatformDeploy \
--role-session-name preflight \
--external-id c7f0a2d1-platform \
--query 'AssumedRoleUser.Arn' --output text
2. Initialise the stack with an explicit secrets provider
The secrets provider is fixed at stack creation and re-encrypting later is a full decrypt-and-rewrite. Choose a customer-managed KMS key for anything that is not a throwaway developer stack.
# CLI: one stack per environment, each with its own key
pulumi stack init prod --secrets-provider="awskms://alias/pulumi-prod?region=eu-west-1"
pulumi config set aws:region eu-west-1
pulumi config set platform:accountId 444455556666
3. Declare providers in one module and import them everywhere
Put every aws.Provider construction in a single providers.py. No other module should call aws.Provider, which makes "how many identities does this stack use" a question you answer by reading one file.
# __main__.py — the entry point wires providers to components, nothing else
# CLI: pulumi up --stack prod
from __future__ import annotations
import pulumi
from providers import platform_provider
cfg = pulumi.Config("platform")
prod = platform_provider("workload", region="eu-west-1", account_id=cfg.require("accountId"))
# Provider note: components take providers=[...] (plural) so children inherit the binding
from network import NetworkStack # noqa: E402
network = NetworkStack(
"core",
cidr_block="10.40.0.0/16",
opts=pulumi.ResourceOptions(providers=[prod]),
)
pulumi.export("vpcId", network.vpc_id)
pulumi.export("privateSubnetIds", network.private_subnet_ids)
4. Gate the first deployment behind a preview you have actually read
pulumi preview --diff on a new stack prints a create for every resource. Read the provider column, confirm the account and region, and only then run pulumi up. On subsequent runs the diff is short enough that anything unexpected stands out.
Verification
# CLI: confirm which identity and account the stack deployed through
pulumi stack output deployAccountId
pulumi stack output deployRoleArn
# CLI: list every provider resource in state — expect only the ones you declared
pulumi stack --show-urns | grep 'pulumi:providers:aws'
# CLI: confirm no resource silently fell back to the default provider
pulumi stack export | grep -c 'default_6_66_2'
# CLI: prove the checkpoint is where you think it is
pulumi stack ls --json | python3 -c "import json,sys; print([s['name'] for s in json.load(sys.stdin)])"
aws s3 ls s3://infra-state-bucket/.pulumi/stacks/platform/
A count of zero from the default_6_66_2 grep is the assertion worth adding to a review checklist: it proves every resource is bound to a provider you declared, in an account you named, rather than to whatever the environment happened to supply.
Common Mistakes & Anti-Patterns
- Omitting Python 3.9+ type hints, leading to silent configuration drift and failed previews.
- Hardcoding AWS credentials in
Pulumi.yamlor environment files instead of using OIDC or secret managers. - Sharing a single provider instance across multiple accounts without explicit aliasing, causing cross-account resource collisions.
- Skipping DynamoDB state locking, resulting in race conditions during parallel CI/CD pipeline executions.
- Relying on live AWS calls in unit tests instead of
motomocks, causing slow test suites and flaky CI runs. - Forgetting to call
register_outputs()inComponentResourcesubclasses, causing outputs to not appear inpulumi stack output.
Troubleshooting
error: unable to validate AWS credentials. Make sure you have set your AWS region, e.g. 'pulumi config set aws:region us-west-2'. — the plugin found no region at all. Either set aws:region in stack config or pass region= to the provider explicitly. The message mentions only the region because region resolution happens before credential validation.
operation error STS: GetCallerIdentity, https response error StatusCode: 403, api error InvalidClientTokenId: The security token included in the request is invalid — the resolved credentials are not valid in the region being used, usually because a session token was issued in a partition or region the request does not match. Re-run aws sts get-caller-identity with the same environment to confirm whether the problem is Pulumi or the shell.
api error ExpiredToken: The security token included in the request is expired — a long update outlived its session. Increase the role's MaxSessionDuration and request a longer duration on assume_role, or split the stack so no single update runs for hours.
api error AccessDenied: User: arn:aws:sts::111122223333:assumed-role/GitHubActionsPulumi/gha-pulumi is not authorized to perform: sts:AssumeRole on resource: arn:aws:iam::444455556666:role/PlatformDeploy — the trust policy on the target role does not name the calling principal, or external_id is missing or wrong. The identity in the error is the base identity, which is the fastest clue to which link of the chain broke.
api error AccessDenied: Not authorized to perform sts:AssumeRoleWithWebIdentity — the OIDC trust policy's sub condition does not match the workflow. GitHub's subject includes the branch or environment, so a role that trusts repo:org/infra:ref:refs/heads/main rejects a run from a pull request.
error: AWS account ID not allowed: 111122223333 — allowed_account_ids did its job. The ambient credentials point somewhere other than the account this provider is meant to manage. This is a success, not a bug.
error: the stack is currently locked by 1 lock(s). Either wait for the other process(es) to end or manually delete the lock file(s). — a self-managed backend lock is held. If the holding process is genuinely gone, pulumi cancel clears it; deleting the lock object by hand while an update is running corrupts the checkpoint.
error: no resource plugin 'aws' found in the workspace or on your $PATH — the Python package is installed but the matching plugin binary is not, common in slim CI images. pulumi plugin install resource aws 6.66.2 fixes it, and pinning the version in ResourceOptions keeps the two in step.
operation error EC2: DescribeVpcs, https response error StatusCode: 503, api error RequestLimitExceeded: Request limit exceeded. — API throttling during a wide parallel deployment. Raise max_retries, set retry_mode="adaptive", or reduce concurrency with pulumi up --parallel 8.
error: Duplicate resource URN 'urn:pulumi:prod::platform::aws:ec2/vpc:Vpc::app-vpc'; try giving it a unique name — two registrations produced the same logical name, almost always a loop that forgot to interpolate its index into the resource name.
Key Takeaways
The AWS provider layer reduces to four rules: instantiate providers explicitly, one per account and region, and bind every resource to one; guard each provider with allowed_account_ids so a credential mistake fails loudly; pick the backend, the secrets provider and the locking mode deliberately before the first pulumi up, because all three are expensive to change afterwards; and keep the fast feedback loop credential-free with mypy, Pulumi mocks and moto so CI only needs AWS access for the deployment itself.
FAQ
How do I enforce Python 3.9+ typing for AWS provider configurations?
Define configuration schemas with typing.TypedDict or a frozen dataclass, and validate them with Pydantic before they reach aws.Provider. Run mypy in CI as a job that needs no AWS credentials at all, so a mistyped region or a missing account ID fails in seconds rather than during a deployment.
What is the safest way to manage AWS credentials in CI/CD pipelines?
Federate with OIDC — GitHub Actions with permissions: id-token: write, or IAM Roles Anywhere for self-hosted runners — and configure assume_role_with_web_identity on the provider. No static access key then exists in the repository, the runner, or Pulumi config, and every deployment appears in CloudTrail under a session name you chose.
How does Pulumi handle two engineers running pulumi up at the same time?
Pulumi Cloud serialises updates server-side and returns [409] Conflict: Another update is currently in progress. A self-managed S3 backend does the same with lock objects under .pulumi/locks/, but only when PULUMI_SELF_MANAGED_STATE_LOCKING=1 is set — there is no DynamoDB lock table, which is the habit most Terraform migrants bring with them.
Can I mock AWS API calls when unit testing Pulumi Python code?
Use both layers. moto's mock_aws intercepts boto3 for helper code that calls AWS directly, and pulumi.runtime.set_mocks() intercepts resource registration so no plugin process starts. moto cannot intercept the pulumi-aws plugin, because that plugin is a separate Go process with its own SDK.
Why did my resources deploy to the wrong AWS account?
Ambient credentials outranked what you thought you configured — an AWS_ACCESS_KEY_ID in the environment beats a profile argument, and with no explicit provider Pulumi builds a default one from whatever the chain returns. Set allowed_account_ids on every provider and export aws.get_caller_identity_output().account_id as a stack output so the account is visible in review.
Should I use pulumi_aws or pulumi_aws_native?
Use pulumi_aws for essentially everything: coverage is complete, import is mature, and diff behaviour is well understood. Reach for pulumi_aws_native only when a brand-new service has no bridged resource yet, and confine that usage to one component so the boundary follows a service rather than a single resource.
Related
- Managing multi-account AWS environments with Pulumi Python — cross-account STS role chaining and per-account provider routing.
- Securing Pulumi secrets with AWS KMS and HashiCorp Vault — KMS-backed state encryption and external secret backends.
- How to Deploy an EKS Cluster with Pulumi (Python) — managed Kubernetes with node groups, IRSA, and kubeconfig output.
- Pulumi Patterns & Provider Management — the parent section covering stacks, components, and the Automation API.