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.

One Pulumi project deployed as two stacks One Pulumi project, with one Python program, is deployed as two stacks. The dev stack reads Pulumi.dev.yaml, where versioning is false, and creates a private S3 bucket with public access blocked. The prod stack reads Pulumi.prod.yaml, where versioning is true, and creates its own private bucket with versioning enabled. Each stack keeps its own state in the backend. Stacks AWS Cloud · us-east-1 Pulumi project · artifacts __main__.py · Pulumi.yaml one program, written once Stack: dev Pulumi.dev.yaml versioning: false Stack: prod Pulumi.prod.yaml versioning: true S3 bucket · artifacts-<suffix> public access blocked versioning: off S3 bucket · artifacts-<suffix> public access blocked versioning: on same code pulumi up --stack dev pulumi up --stack prod program and config deploy
The example in this post: one program, two stacks. Each stack reads its own config file, so prod gets versioning and dev does not.

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_bool returns None if the key is not set, so or False gives 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, so dev and prod never collide.
  • Outputs. bucket.id is 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, use pulumi.Output.concat().
  • It is just Python. The if decides whether versioning exists in this stack at all.
  • pulumi.export publishes 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.” Use Output.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 the aliases resource 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.

References