⏺ Pulumi: A Guide for CDK/Terraform/SST Veterans
Given your background, Pulumi will feel like the best parts of CDK and Terraform combined. Here's everything you need to know.
- The Mental Model
┌────────────────────┬──────────────────────────┬───────────────────────────────────┬──────────────────────────────┬──────────────────────────────┐
│ Concept │ Terraform │ CDK │ SST v2 │ Pulumi │
├────────────────────┼──────────────────────────┼───────────────────────────────────┼──────────────────────────────┼──────────────────────────────┤
│ Language │ HCL │ TypeScript/Python │ TypeScript │ Python/TS/Go/C#/Java/YAML │
├────────────────────┼──────────────────────────┼───────────────────────────────────┼──────────────────────────────┼──────────────────────────────┤
│ State │ .tfstate (S3 + DynamoDB) │ CloudFormation stacks │ CloudFormation stacks │ Pulumi Cloud / S3 backend │
├────────────────────┼──────────────────────────┼───────────────────────────────────┼──────────────────────────────┼──────────────────────────────┤
│ Unit of deployment │ Root module │ Stack │ Stack │ Stack │
├────────────────────┼──────────────────────────┼───────────────────────────────────┼──────────────────────────────┼──────────────────────────────┤
│ Environments │ Workspaces │ -c stage=dev │ sst dev / sst deploy --stage │ Stacks (dev/staging/prod) │
├────────────────────┼──────────────────────────┼───────────────────────────────────┼──────────────────────────────┼──────────────────────────────┤
│ Abstractions │ Modules │ Constructs (L1/L2/L3) │ Constructs │ Component Resources │
├────────────────────┼──────────────────────────┼───────────────────────────────────┼──────────────────────────────┼──────────────────────────────┤
│ Provider model │ Providers (HCL config) │ CloudFormation + custom resources │ CloudFormation │ Providers (bridged from TF!) │
├────────────────────┼──────────────────────────┼───────────────────────────────────┼──────────────────────────────┼──────────────────────────────┤
│ Plan/preview │ tofu plan │ cdk diff │ sst diff │ pulumi preview │
├────────────────────┼──────────────────────────┼───────────────────────────────────┼──────────────────────────────┼──────────────────────────────┤
│ Apply │ tofu apply │ cdk deploy │ sst deploy │ pulumi up │
├────────────────────┼──────────────────────────┼───────────────────────────────────┼──────────────────────────────┼──────────────────────────────┤
│ Destroy │ tofu destroy │ cdk destroy │ sst remove │ pulumi destroy │
└────────────────────┴──────────────────────────┴───────────────────────────────────┴──────────────────────────────┴──────────────────────────────┘
Key insight: Pulumi providers are largely auto-generated from Terraform providers via the pulumi-terraform-bridge. So aws_lambda_function in TF becomes aws.lambda_.Function in Pulumi Python. The mapping is nearly 1:1.
- Core Concepts
Projects and Stacks
A project is a directory with a Pulumi.yaml file (like cdk.json or sst.config.ts). A stack is an instance of that project with its own config and state — analogous to TF workspaces but much better.
my-infra/
├── Pulumi.yaml # Project definition (name, runtime, description)
├── Pulumi.dev.yaml # Stack-specific config for "dev"
├── Pulumi.staging.yaml # Stack-specific config for "staging"
├── Pulumi.prod.yaml # Stack-specific config for "prod"
├── __main__.py # Your infrastructure code
└── requirements.txt # or pyproject.toml
# Pulumi.yaml
name: hawk-infra
runtime:
name: python
options:
virtualenv: venv
description: Hawk infrastructure
Inputs and Outputs
This is where your CDK experience pays off. Pulumi has Input[T] and Output[T] types — exactly like CDK's Token/IResolvable system:
import pulumi
import pulumi_aws as aws
# Creating a resource — returns an object with Output properties
bucket = aws.s3.Bucket("my-bucket",
tags={"Environment": pulumi.get_stack()} # get_stack() returns "dev", "staging", etc.
)
# bucket.id is Output[str], not str — just like CDK tokens
# You can't just print() it. You transform it:
bucket_url = bucket.id.apply(lambda name: f"https://{name}.s3.amazonaws.com")
# Or use pulumi.Output.concat (like Fn.join in CDK):
full_url = pulumi.Output.concat("https://", bucket.bucket_domain_name)
# Export values (like CfnOutput in CDK, or output in TF):
pulumi.export("bucket_name", bucket.id)
pulumi.export("bucket_url", bucket_url)
CDK comparison: Output[T] = IResolvable / Token. .apply(fn) = Fn.select() / Lazy.string(). The key difference: Pulumi's .apply() runs real Python lambdas at deploy time, which is far more
ergonomic than CDK's intrinsic functions.
Config and Secrets
import pulumi
config = pulumi.Config()
# Plain config (like TF variables or CDK context)
environment = config.require("environment") # Must exist
region = config.get("region") or "us-east-1" # Optional with default
# Secrets — encrypted in state, never shown in logs
db_password = config.require_secret("db_password") # Returns Output[str]
Set config from CLI:
pulumi config set environment dev
pulumi config set --secret db_password "hunter2" # Encrypted!
Huge advantage over Terraform: Secrets are a first-class concept. No more sensitive = true that still leaks in state files. Pulumi encrypts secrets in the state itself.
- Resource Model (Where TF Knowledge Transfers)
Since Pulumi providers are bridged from TF providers, the resource names map almost directly:
import pulumi_aws as aws
vpc = aws.ec2.Vpc("main", cidr_block="10.0.0.0/16")
cluster = aws.ecs.Cluster("cluster", name="hawk")
fn = aws.lambda_.Function("fn", # Note: lambda_ because lambda is a Python keyword runtime="python3.12", handler="handler.handler", role=role.arn, code=pulumi.FileArchive("./lambda_code"), )
role = aws.iam.Role("role", assume_role_policy=json.dumps({ "Version": "2012-10-17", "Statement": [{ "Action": "sts:AssumeRole", "Principal": {"Service": "lambda.amazonaws.com"}, "Effect": "Allow", }], }), )
The naming convention: TF's aws__ becomes aws.. in Pulumi Python.
Resource Options (Lifecycle, Depends On, etc.)
TF concepts map to ResourceOptions:
import pulumi
bucket = aws.s3.Bucket("important-bucket", opts=pulumi.ResourceOptions( protect=True, # TF: prevent_destroy depends_on=[some_other_resource], # TF: depends_on (rarely needed) provider=custom_provider, # TF: provider = aws.west ignore_changes=["tags"], # TF: lifecycle { ignore_changes } delete_before_replace=True, # TF: create_before_destroy = false retain_on_delete=True, # Don't delete cloud resource on pulumi destroy import_="existing-bucket-name", # TF: import block ), )
- Component Resources (Your CDK Constructs)
This is where Pulumi really shines vs Terraform modules. Component Resources are like CDK L2/L3 constructs:
import pulumi import pulumi_aws as aws
class LambdaWithLogging(pulumi.ComponentResource): """Like a CDK L3 construct — bundles multiple resources."""
def __init__(self, name: str, handler: str, runtime: str,
code_path: str, opts: pulumi.ResourceOptions | None = None):
super().__init__("hawk:lambda:LambdaWithLogging", name, {}, opts)
# Child resources are registered under this component
self.role = aws.iam.Role(f"{name}-role",
assume_role_policy=json.dumps({...}),
opts=pulumi.ResourceOptions(parent=self), # Key: parent=self
)
self.log_group = aws.cloudwatch.LogGroup(f"{name}-logs",
retention_in_days=14,
opts=pulumi.ResourceOptions(parent=self),
)
self.function = aws.lambda_.Function(f"{name}-fn",
runtime=runtime,
handler=handler,
role=self.role.arn,
code=pulumi.FileArchive(code_path),
opts=pulumi.ResourceOptions(parent=self),
)
# Expose outputs
self.arn = self.function.arn
self.name = self.function.name
# Register outputs (for display in pulumi up)
self.register_outputs({
"arn": self.arn,
"function_name": self.name,
})
eval_updated = LambdaWithLogging("eval-updated", handler="handler.handler", runtime="python3.12", code_path="./terraform/modules/eval_updated", )
SST comparison: This is similar to how SST v2's Function construct wraps aws-cdk-lib.aws_lambda.Function with sensible defaults. You build your own higher-level abstractions.
- State Management
Backend Options
pulumi login
pulumi login s3://my-pulumi-state-bucket
pulumi login --local
For your case: Since you already have S3 + DynamoDB for TF state, you can use s3:// backend. But Pulumi Cloud's free tier is generous and gives you a UI for state, history, and diffs.
State Operations
pulumi stack export # Dump state (like terraform state pull) pulumi stack import # Import state pulumi state delete # Remove resource from state (like terraform state rm) pulumi import # Import existing resource
- Stacks = Environments (Better Than TF Workspaces)
pulumi stack init dev pulumi stack init staging pulumi stack init prod
pulumi stack select dev # Switch to dev pulumi up # Deploy dev
pulumi stack select prod pulumi up # Deploy prod
Each stack has its own config file and state. In your code:
stack = pulumi.get_stack() # "dev", "staging", "prod" config = pulumi.Config()
instance_type = { "dev": "t3.small", "staging": "t3.medium", "prod": "t3.xlarge", }[stack]
This is like SST's --stage flag, but built into the core platform.
- Migrating Your Terraform
You have three approaches:
A. Automated conversion (pulumi convert)
cd terraform/ pulumi convert --from terraform --language python --out ../pulumi-infra
This handles 90-95% of conversions automatically. It reads your .tf files and generates equivalent Pulumi code.
B. State migration (import existing resources)
pulumi import --from terraform ./terraform.tfstate
This reads your TF state and creates pulumi import commands for every resource, so Pulumi adopts management of existing cloud resources without recreating them.
C. Coexistence (recommended for gradual migration)
You can use StackReference to read outputs from other stacks — or just read TF state directly:
import json
tf_state = json.loads(open("terraform.tfstate").read()) vpc_id = tf_state["outputs"]["vpc_id"]["value"]
other_stack = pulumi.StackReference("org/other-project/prod") vpc_id = other_stack.get_output("vpc_id")
- Real Python Power (Why Pulumi > TF for Complex Infra)
This is where Pulumi obliterates HCL. Things that are painful in Terraform become trivial:
services = ["eval-updated", "eval-log-importer", "eval-log-reader", "token-refresh"]
lambdas = {} for svc in services: lambdas[svc] = LambdaWithLogging(svc, handler="handler.handler", runtime="python3.12", code_path=f"./modules/{svc}", )
if stack == "prod": aws.cloudwatch.MetricAlarm("high-errors", ...)
def tag_all(name: str, extra_tags: dict | None = None) -> dict: base = {"Project": "hawk", "Environment": stack, "ManagedBy": "pulumi"} if extra_tags: base.update(extra_tags) return base
bucket = aws.s3.Bucket("logs", tags=tag_all("logs", {"Retention": "90d"}))
- Testing Infrastructure
Pulumi has unit testing support — something TF only approximates with terraform test:
import pulumi
class MyMocks(pulumi.runtime.Mocks): def new_resource(self, args): return [args.name + "_id", args.inputs]
def call(self, args):
return {}
pulumi.runtime.set_mocks(MyMocks())
import main as infra
@pulumi.runtime.test def test_bucket_has_tags(): def check_tags(tags): assert tags is not None assert "Environment" in tags return infra.bucket.tags.apply(check_tags)
This is similar to CDK's assertions module but uses native pytest.
- Pulumi vs CDK vs SST: Honest Trade-offs
┌─────────────────────┬────────────────────┬──────────────────────────────────────────────────────────────────────────────┐ │ Dimension │ Winner │ Why │ ├─────────────────────┼────────────────────┼──────────────────────────────────────────────────────────────────────────────┤ │ Multi-cloud │ Pulumi │ CDK/SST are AWS-only. Pulumi supports AWS, GCP, Azure, K8s, Cloudflare, etc. │ ├─────────────────────┼────────────────────┼──────────────────────────────────────────────────────────────────────────────┤ │ Provider coverage │ Tie │ Pulumi bridges TF providers, so coverage is equivalent │ ├─────────────────────┼────────────────────┼──────────────────────────────────────────────────────────────────────────────┤ │ Abstraction quality │ CDK/SST │ CDK L2 constructs are more polished. Pulumi's awsx is good but thinner │ ├─────────────────────┼────────────────────┼──────────────────────────────────────────────────────────────────────────────┤ │ State management │ Pulumi │ First-class secrets, better import story │ ├─────────────────────┼────────────────────┼──────────────────────────────────────────────────────────────────────────────┤ │ Speed │ Pulumi │ No CloudFormation intermediary. Direct API calls. │ ├─────────────────────┼────────────────────┼──────────────────────────────────────────────────────────────────────────────┤ │ Ecosystem │ Terraform │ More modules, examples, Stack Overflow answers │ ├─────────────────────┼────────────────────┼──────────────────────────────────────────────────────────────────────────────┤ │ Dev experience │ SST > Pulumi > CDK │ SST's sst dev is unmatched. Pulumi's pulumi watch is decent │ ├─────────────────────┼────────────────────┼──────────────────────────────────────────────────────────────────────────────┤ │ Debugging │ Pulumi │ Real stack traces in your language. CDK errors come from CloudFormation │ └─────────────────────┴────────────────────┴──────────────────────────────────────────────────────────────────────────────┘
- Getting Started (Practical Steps)
brew install pulumi/tap/pulumi
mkdir hawk-pulumi && cd hawk-pulumi pulumi new aws-python # Interactive setup
cd /Users/mish/dev/inspect-action/terraform pulumi convert --from terraform --language python --out ../pulumi-infra
pulumi up
pulumi preview # Like tofu plan pulumi up # Like tofu apply pulumi destroy # Like tofu destroy pulumi stack ls # List all stacks pulumi config set k v # Set config pulumi logs # View cloud logs (!) pulumi refresh # Sync state with reality (like tofu refresh)
- Pulumi-Specific Things That Don't Exist in TF
- pulumi watch: Auto-deploy on file save (like sst dev lite)
- pulumi logs: Stream CloudWatch logs from your Pulumi-managed resources
- Automation API: Run Pulumi programmatically from Python (embed in your API server, CI, etc.)
- Policy as Code (CrossGuard): Write OPA-style policies in Python
- Pulumi AI: Generate infrastructure code from natural language descriptions
My Recommendation for Your Migration
Given that Hawk uses OpenTofu with modules for Lambdas, ECS, Aurora, etc.:
- Start with pulumi convert --from terraform to see what the auto-generated code looks like
- Migrate one module at a time — start with something self-contained like token_refresh Lambda
- Use S3 backend to keep it similar to your current TF state setup
- Build Component Resources for your repeating patterns (Lambda + LogGroup + IAM, etc.)
- Use Python since your whole codebase is Python — one language for app and infra
Want me to do a trial pulumi convert on your terraform directory to see what it produces?
Sources:
- Pulumi for Terraform Users
- Terraform vs Pulumi Comparison
- Migrating from Terraform to Pulumi
- Get Started with Pulumi and AWS
- Convert Terraform to Pulumi
- Converting Full Terraform Programs to Pulumi
- Terraform Migration Guide (Community)
- Real Migration Experience
- Terraform vs Pulumi vs CDK Decision Framework