Pulumi takes the same "real language for IaC" premise as CDK but with a different execution model. Instead of synthesising to CloudFormation, Pulumi talks directly to cloud provider APIs, maintains its own state (like Terraform), and supports every cloud from one program. If your stack touches AWS, Azure, GCP, and Kubernetes simultaneously, Pulumi may be the better choice.
The Execution Model
| AWS CDK | Pulumi | |
|---|---|---|
| Execution | TypeScript → CloudFormation → AWS API | TypeScript → Pulumi Engine → Cloud API directly |
| State | CloudFormation manages state | Pulumi state backend (Pulumi Cloud, S3, Azure Blob, GCS) |
| Providers | AWS only (via CloudFormation) | 100+ providers; any Terraform provider |
| Drift detection | CloudFormation drift detection | pulumi refresh reconciles state |
| Import existing | cdk import | pulumi import |
Setup
npm install -g @pulumi/pulumi
pulumi new aws-typescript
# Or Azure
pulumi new azure-typescript
# Or multi-cloud
pulumi new typescript
The pulumi new scaffolds:
my-infra/
├── index.ts # entry point — the Pulumi program
├── Pulumi.yaml # project config
├── Pulumi.dev.yaml # per-stack config (dev stack)
└── package.json
A First Program
// index.ts
import * as pulumi from "@pulumi/pulumi"
import * as aws from "@pulumi/aws"
import * as cloudfront from "@pulumi/aws-native" // sometimes used for newer resources
const config = new pulumi.Config()
const stage = config.require("stage")
// S3 bucket
const bucket = new aws.s3.BucketV2("static", {
tags: {
Name: `static-${stage}`,
Environment: stage,
ManagedBy: "pulumi",
},
})
new aws.s3.BucketVersioningV2("versioning", {
bucket: bucket.id,
versioningConfiguration: { status: "Enabled" },
})
new aws.s3.BucketPublicAccessBlock("block-public", {
bucket: bucket.id,
blockPublicAcls: true,
blockPublicPolicy: true,
ignorePublicAcls: true,
restrictPublicBuckets: true,
})
// Export the bucket name
export const bucketName = bucket.id
export const bucketArn = bucket.arn
Outputs — The Key Concept
Resources return properties as pulumi.Output<T>. These are like CDK Tokens — they resolve asynchronously after the resource is created. Do not unwrap them with .get() — use apply:
// Wrong
const name = bucket.id.get() // throws at design time
// Right — apply is like .then() on a Promise
const name = bucket.id.apply((id) => `s3://${id}/data`)
// Combine multiple outputs
const url = pulumi.all([distribution.domainName, bucket.id]).apply(
([domain, bucketId]) => `https://${domain}/${bucketId}`
)
// Output.concat for string interpolation
const path = pulumi.interpolate`s3://${bucket.id}/prefix`
Stack Configuration
# Pulumi.dev.yaml
config:
aws:region: us-east-1
my-infra:stage: dev
my-infra:vpcCidr: "10.0.0.0/16"
# Pulumi.prod.yaml
config:
aws:region: eu-west-1
my-infra:stage: prod
my-infra:vpcCidr: "10.1.0.0/16"
const config = new pulumi.Config()
const stage = config.require("stage") // required; throws if absent
const vpcCidr = config.get("vpcCidr") ?? "10.0.0.0/16"
const dbPassword = config.requireSecret("dbPassword") // reads a Pulumi secret
Secrets are encrypted at rest in the state backend. Set with:
pulumi config set --secret dbPassword "S3cur3Pa55w0rd"
Component Resources — Reusable Constructs
import * as pulumi from "@pulumi/pulumi"
import * as aws from "@pulumi/aws"
interface SecureBucketArgs {
purpose: string
team: string
stage: string
}
class SecureBucket extends pulumi.ComponentResource {
public readonly bucket: aws.s3.BucketV2
constructor(name: string, args: SecureBucketArgs, opts?: pulumi.ComponentResourceOptions) {
super("acme:storage:SecureBucket", name, {}, opts)
this.bucket = new aws.s3.BucketV2(`${name}-bucket`, {
tags: { team: args.team, purpose: args.purpose, stage: args.stage },
}, { parent: this })
new aws.s3.BucketVersioningV2(`${name}-versioning`, {
bucket: this.bucket.id,
versioningConfiguration: { status: "Enabled" },
}, { parent: this })
new aws.s3.BucketPublicAccessBlock(`${name}-block-public`, {
bucket: this.bucket.id,
blockPublicAcls: true,
blockPublicPolicy: true,
ignorePublicAcls: true,
restrictPublicBuckets: true,
}, { parent: this })
this.registerOutputs({ bucketId: this.bucket.id })
}
}
// Use it
const dataBucket = new SecureBucket("data", {
purpose: "analytical-data",
team: "data-engineering",
stage: "prod",
})
The { parent: this } option builds a logical resource tree for pulumi stack --show-urns and dependency tracking.
Multi-Cloud in One Program
import * as aws from "@pulumi/aws"
import * as azure from "@pulumi/azure-native"
import * as k8s from "@pulumi/kubernetes"
// AWS S3 backup bucket
const s3Backup = new aws.s3.BucketV2("backup", {})
// Azure Blob for DR
const rg = new azure.resources.ResourceGroup("dr-rg", { location: "westeurope" })
const blobAccount = new azure.storage.StorageAccount("dr", {
resourceGroupName: rg.name,
kind: "StorageV2",
sku: { name: "Standard_LRS" },
})
// Kubernetes secret referencing AWS bucket
const k8sSecret = new k8s.core.v1.Secret("aws-backup-config", {
metadata: { name: "backup-config", namespace: "production" },
stringData: { bucketName: s3Backup.id },
})
All three providers in one program, one pulumi up.
Daily Workflow
pulumi stack init dev
pulumi stack select dev
# Preview (like cdk diff)
pulumi preview
# Deploy
pulumi up
# See outputs
pulumi stack output
# Refresh state from cloud
pulumi refresh
# Destroy
pulumi destroy
# Manage stacks
pulumi stack ls
pulumi stack export # export raw state
Pulumi Automation API
The Automation API lets you embed Pulumi in a TypeScript application — a CI system, a provisioning API, a testing harness:
import { LocalWorkspace, ConcurrentUpdateError } from "@pulumi/pulumi/automation"
async function provisionTenant(tenantId: string): Promise<string> {
const stack = await LocalWorkspace.createOrSelectStack({
stackName: `tenant-${tenantId}`,
workDir: "./infra",
})
await stack.setConfig("tenantId", { value: tenantId })
await stack.workspace.installPlugin("aws", "v5.0.0")
await stack.up({ onOutput: console.info })
const outputs = await stack.outputs()
return outputs["endpointUrl"].value as string
}
This pattern powers platforms that provision customer environments on demand — the same code a human runs manually, now callable from an API.
CDK vs Pulumi — When to Choose
| CDK | Pulumi |
|---|---|
| AWS-only or AWS-primary | Multi-cloud (AWS + Azure + GCP + Kubernetes) |
| Team uses CloudFormation already | Team uses Terraform or wants non-CF state |
| CDK Pipelines for CodePipeline-native CI | Pulumi Automation API for custom provisioning |
| AWS Solutions Constructs ecosystem | Terraform provider coverage needed |
| Extensive AWS-specific L2 abstractions | Pure TypeScript; no CloudFormation quirks |
Both are excellent and the TypeScript skills transfer completely. The final lesson shows the third member of the family — cdk8s, where TypeScript writes Kubernetes YAML.