Getting Started with Pulumi and Python on AWS
What this post is about: Pulumi itself, not AWS. I show how to create a Pulumi project, what the generated files do, how to set up and configure stacks, and the commands you use every day. The example is a single private S3 bucket, kept small on purpose so the Pulumi parts stand out.
The complete project is on GitHub: luisgb14/pulumi-python-aws-getting-started. Clone it to follow along, or build it from scratch with the steps below.
Pulumi does the same job as Terraform: it creates and updates cloud resources from code and tracks them in a state file. The difference is that you write the code in a general-purpose language, here Python.
Four words to know first
| Concept | What it is | In Terraform |
|---|---|---|
| Project | A folder with a Pulumi.yaml file and your Python program. |
A root module |
| Stack | One deployed copy of the project, like dev or prod, with its own config and state. |
A workspace + .tfvars |
| Config | Settings per stack, including encrypted secrets. | Variables |
| Backend | Where the state is stored: Pulumi Cloud, an S3 bucket, or your disk. | The backend block |
The key idea: one project, many stacks. You write the code once and deploy it as many times as you need, each stack with different settings.
Step 1: install and log in
You need the Pulumi CLI (brew install pulumi/tap/pulumi on macOS, or see the install page), Python with uv, and AWS credentials in your shell, the same ones the AWS CLI uses.
Then choose where the state lives:
pulumi login # Pulumi Cloud (free for individuals)
pulumi login s3://my-pulumi-state # an S3 bucket you own
pulumi login --local # files on your disk, for trying things out
With S3 or local state, Pulumi encrypts secrets with a passphrase, which it asks for or reads from PULUMI_CONFIG_PASSPHRASE. Keep it safe: without it, you cannot read your secrets.
Step 2: create the project
mkdir artifacts && cd artifacts
pulumi new aws-python
pulumi new asks for the project name, a description, the first stack name (dev), the Python toolchain (I pick uv) and the AWS region. It then creates the files, installs the dependencies and creates the dev stack:
artifacts/
├── Pulumi.yaml # the project
├── Pulumi.dev.yaml # config for the dev stack
├── __main__.py # the program
├── pyproject.toml # Python dependencies (pulumi, pulumi-aws)
└── uv.lock
Pulumi.yaml names the project and tells Pulumi how to run it:
name: artifacts
description: A private S3 bucket for build artifacts
runtime:
name: python
options:
toolchain: uv
config:
pulumi:tags:
value:
pulumi:template: aws-python
The pulumi:tags block just records which template created the project; you can ignore it.
Pulumi.dev.yaml holds the dev stack’s configuration. For now, only the region (the encryptionsalt line appears with S3 or local state, for the passphrase):
encryptionsalt: v1:8I/5ExCvcvc=:v1:1zIuOKUym0nFsK2P:mKsrCS6DEITboHL0/gjBaDF03WmyrA==
config:
aws:region: us-east-1
Commit all of these except the .venv/ folder. The stack files are part of the project.
Step 3: write the program
The template’s __main__.py creates one bucket. Replace it with this:
"""A private S3 bucket for build artifacts, configured per stack."""
import pulumi
import pulumi_aws as aws
config = pulumi.Config()
versioning = config.get_bool("versioning") or False
tags = {
"project": pulumi.get_project(),
"stack": pulumi.get_stack(),
"managed-by": "pulumi",
}
bucket = aws.s3.Bucket("artifacts", tags=tags)
aws.s3.BucketPublicAccessBlock(
"artifacts",
bucket=bucket.id,
block_public_acls=True,
block_public_policy=True,
ignore_public_acls=True,
restrict_public_buckets=True,
)
if versioning:
aws.s3.BucketVersioning(
"artifacts",
bucket=bucket.id,
versioning_configuration={"status": "Enabled"},
)
pulumi.export("bucket_name", bucket.id)
pulumi.export("bucket_uri", pulumi.Output.concat("s3://", bucket.id))
pulumi.export("versioning", versioning)
Five Pulumi ideas in 30 lines:
pulumi.Config()reads the configuration of the stack being deployed.get_boolreturnsNoneif the key is not set, soor Falsegives a default.- Resources are Python objects. The first argument,
"artifacts", is the logical name Pulumi uses in its state. The real bucket name gets a random suffix, sodevandprodnever collide. - Outputs.
bucket.idis not a string yet; AWS only knows it after the bucket exists. You can pass it to other resources directly, and Pulumi creates things in the right order. To combine it with text, usepulumi.Output.concat(). - It is just Python. The
ifdecides whether versioning exists in this stack at all. pulumi.exportpublishes values you can read after deploying.
Step 4: stacks and config
pulumi new already created dev. Set its config, and create prod with different values:
pulumi config set versioning false
pulumi stack init prod
pulumi config set aws:region us-east-1
pulumi config set versioning true
stack init creates the stack and selects it, and the config set commands create its own file, Pulumi.prod.yaml, next to Pulumi.dev.yaml. The selected stack is the one every command acts on, marked with *:
$ pulumi stack select dev
$ pulumi stack ls
NAME LAST UPDATE RESOURCE COUNT
dev* 13 seconds ago 0
prod now 0
Each value lands in the stack’s file. A key without a prefix belongs to the project, so versioning is saved as artifacts:versioning; keys like aws:region configure a provider.
For secrets, add --secret. This example does not need one, but this is how a webhook URL or a password would go in:
$ pulumi config set --secret notifyWebhook https://hooks.example.com/abc123
$ pulumi config
KEY VALUE
notifyWebhook [secret]
versioning false
aws:region us-east-1
pulumi:tags {"pulumi:template":"aws-python"}
The value is encrypted in Pulumi.dev.yaml, so the file is still safe to commit:
encryptionsalt: v1:8I/5ExCvcvc=:v1:1zIuOKUym0nFsK2P:mKsrCS6DEITboHL0/gjBaDF03WmyrA==
config:
aws:region: us-east-1
artifacts:versioning: "false"
artifacts:notifyWebhook:
secure: v1:LYwJ13T0P9j2H1Kz:7yusjEvYR/RcloVlCwnoM+3+spQBuvJUqPw5CdIgAnSvtkrWWTjeMybfHstwG8uB
In code, you would read it with config.require_secret("notifyWebhook"), which keeps it masked in logs and encrypted in the state. pulumi config --show-secrets reveals it when you need to check it.
Step 5: preview, deploy, use
pulumi preview shows what would change, without changing anything, like terraform plan. Here is the same program previewed against each stack:
$ pulumi preview --stack dev
Previewing update (dev):
+ pulumi:pulumi:Stack artifacts-dev create
+ aws:s3:Bucket artifacts create
+ aws:s3:BucketPublicAccessBlock artifacts create
Outputs:
bucket_name: [unknown]
bucket_uri : [unknown]
versioning : false
Resources:
+ 3 to create
$ pulumi preview --stack prod
Previewing update (prod):
+ pulumi:pulumi:Stack artifacts-prod create
+ aws:s3:Bucket artifacts create
+ aws:s3:BucketPublicAccessBlock artifacts create
+ aws:s3:BucketVersioning artifacts create
Outputs:
bucket_name: [unknown]
bucket_uri : [unknown]
versioning : true
Resources:
+ 4 to create
That is the whole point of stacks: same code, different config, different result. The stack itself counts as a resource, which is why you see 3 and 4. bucket_name is [unknown] because AWS only assigns it during the deployment.
Deploy with pulumi up. It shows the same preview and asks for confirmation (--yes skips the question, for CI). Then read the outputs:
pulumi up --stack dev
pulumi stack output bucket_uri --stack dev
aws s3 cp build.zip "$(pulumi stack output bucket_uri --stack dev)/"
When you change the code or config later, the preview marks updates with ~ and replacements with +-. A replacement deletes the resource and creates it again, so always read those lines before you say yes.
To remove everything:
pulumi destroy --stack dev # delete the stack's resources
pulumi stack rm dev # delete the stack, its config and history
Cheat sheet
| Command | What it does |
|---|---|
pulumi new aws-python |
Create a project from a template |
pulumi stack ls / init / select |
List, create, switch stacks |
pulumi config set [--secret] <key> <value> |
Set config for the selected stack |
pulumi preview / up |
See changes / apply them |
pulumi stack output [name] |
Read the outputs |
pulumi refresh |
Sync the state with what really exists in AWS |
pulumi destroy / stack rm |
Delete resources / delete the stack |
Every command accepts --stack <name>, which I prefer in scripts so nothing depends on what was selected before.
Four beginner mistakes
- Deploying to the wrong stack. Pulumi prints the stack name at the top of every preview. Read it, or always pass
--stack. - Treating an Output like a string.
f"{bucket.id}-logs"does not work; Pulumi warns “Calling __str__ on an Output[T] is not supported.” UseOutput.concat()or.apply(). - Renaming a resource. Changing the logical name, say
"artifacts"to"builds", deletes the bucket and creates a new one. If you must rename, use thealiasesresource option. - Plain-text secrets. Anything sensitive goes in with
--secret, never as a normal config value.
Conclusion
Your first hour with Pulumi is about the workflow, not the resources: a project for the code, stacks for the settings and state, and a handful of commands. Once that clicks, every AWS resource is just another Python object. Next steps worth exploring are unit tests with Pulumi’s mocks, ComponentResource classes for reusable building blocks, and running pulumi preview on every pull request.