Pulumi lets you define cloud infrastructure using real TypeScript — loops, functions, types, and tests all work as expected. ComponentResource bundles related resources into reusable abstractions. StackReference reads outputs from other stacks for cross-stack dependencies. pulumi.interpolate composes Output values without losing type safety. Pulumi ESC manages secrets and configuration as typed, audited environments. Claude Code generates Pulumi components, stack configurations, and the test harness that validates infrastructure logic without deploying.
CLAUDE.md for Pulumi Projects
## Pulumi Stack
- Language: TypeScript (strict mode)
- Runtime: Node.js 20+
- Backends: Pulumi Cloud (state + secrets)
- Stacks: one per environment (dev/staging/production)
- Components: in /components — extend ComponentResource
- Secrets: Pulumi ESC with environment-per-stack
- AWS provider: @pulumi/aws ~7.x
- Testing: @pulumi/pulumi mock runtime (unit tests, no deploy)
- Naming: {stack}-{component}-{type} (e.g., prod-api-sg)
Project Structure
pulumi-infra/
├── Pulumi.yaml
├── Pulumi.dev.yaml
├── Pulumi.staging.yaml
├── Pulumi.production.yaml
├── index.ts # Stack entry point
├── components/
│ ├── vpc.ts # VPC ComponentResource
│ ├── ecs-service.ts # ECS ComponentResource
│ └── rds-cluster.ts # RDS ComponentResource
├── config.ts # Typed config schema
├── tests/
│ └── vpc.test.ts # Unit tests with mocks
└── package.json
Typed Stack Configuration
// config.ts — typed configuration from Pulumi config
import * as pulumi from '@pulumi/pulumi'
interface StackConfig {
vpcCidr: string
availabilityZones: string[]
environment: 'dev' | 'staging' | 'production'
enableNatGateway: boolean
dbInstanceClass: string
ecsTaskCpu: number
ecsTaskMemory: number
domainName: string
}
export function loadConfig(): StackConfig {
const config = new pulumi.Config()
return {
vpcCidr: config.get('vpcCidr') ?? '10.0.0.0/16',
availabilityZones: config.requireObject<string[]>('availabilityZones'),
environment: config.require('environment') as StackConfig['environment'],
enableNatGateway: config.getBoolean('enableNatGateway') ?? true,
dbInstanceClass: config.get('dbInstanceClass') ?? 'db.t3.medium',
ecsTaskCpu: config.getNumber('ecsTaskCpu') ?? 256,
ecsTaskMemory: config.getNumber('ecsTaskMemory') ?? 512,
domainName: config.require('domainName'),
}
}
# Pulumi.production.yaml
config:
aws:region: us-east-1
myapp:environment: production
myapp:availabilityZones:
- us-east-1a
- us-east-1b
- us-east-1c
myapp:vpcCidr: 10.0.0.0/16
myapp:enableNatGateway: "true"
myapp:dbInstanceClass: db.r6g.large
myapp:ecsTaskCpu: "1024"
myapp:ecsTaskMemory: "2048"
myapp:domainName: myapp.com
VPC ComponentResource
// components/vpc.ts
import * as pulumi from '@pulumi/pulumi'
import * as aws from '@pulumi/aws'
interface VpcArgs {
cidrBlock: string
availabilityZones: string[]
enableNatGateway: boolean
singleNatGateway?: boolean
tags?: Record<string, string>
}
export class Vpc extends pulumi.ComponentResource {
public readonly vpcId: pulumi.Output<string>
public readonly privateSubnetIds: pulumi.Output<string[]>
public readonly publicSubnetIds: pulumi.Output<string[]>
constructor(name: string, args: VpcArgs, opts?: pulumi.ComponentResourceOptions) {
super('myapp:networking:Vpc', name, {}, opts)
const commonTags = {
...args.tags,
ManagedBy: 'pulumi',
}
const vpc = new aws.ec2.Vpc(`${name}-vpc`, {
cidrBlock: args.cidrBlock,
enableDnsHostnames: true,
enableDnsSupport: true,
tags: { ...commonTags, Name: name },
}, { parent: this })
const igw = new aws.ec2.InternetGateway(`${name}-igw`, {
vpcId: vpc.id,
tags: { ...commonTags, Name: `${name}-igw` },
}, { parent: this })
// Create public subnets
const publicSubnets = args.availabilityZones.map((az, i) => {
const cidr = args.cidrBlock.replace('/16', '') + `0.${100 + i}.0/24`
.replace('0.0.', '')
// Compute subnet CIDR properly
const baseParts = args.cidrBlock.split('.').slice(0, 2).join('.')
const subnetCidr = `${baseParts}.${100 + i}.0/24`
return new aws.ec2.Subnet(`${name}-public-${i}`, {
vpcId: vpc.id,
cidrBlock: `${baseParts}.${200 + i}.0/24`,
availabilityZone: az,
mapPublicIpOnLaunch: true,
tags: {
...commonTags,
Name: `${name}-public-${az}`,
'kubernetes.io/role/elb': '1',
},
}, { parent: this })
})
const privateSubnets = args.availabilityZones.map((az, i) => {
const baseParts = args.cidrBlock.split('.').slice(0, 2).join('.')
return new aws.ec2.Subnet(`${name}-private-${i}`, {
vpcId: vpc.id,
cidrBlock: `${baseParts}.${i + 1}.0/24`,
availabilityZone: az,
mapPublicIpOnLaunch: false,
tags: {
...commonTags,
Name: `${name}-private-${az}`,
'kubernetes.io/role/internal-elb': '1',
},
}, { parent: this })
})
// NAT gateways for private subnet egress
if (args.enableNatGateway) {
const natCount = args.singleNatGateway ? 1 : args.availabilityZones.length
const eips = Array.from({ length: natCount }, (_, i) =>
new aws.ec2.Eip(`${name}-nat-eip-${i}`, {
domain: 'vpc',
tags: { ...commonTags, Name: `${name}-nat-${i}` },
}, { parent: this })
)
const natGateways = eips.map((eip, i) =>
new aws.ec2.NatGateway(`${name}-nat-${i}`, {
allocationId: eip.id,
subnetId: publicSubnets[i].id,
tags: { ...commonTags, Name: `${name}-nat-${i}` },
}, { parent: this, dependsOn: [igw] })
)
// Private route tables pointing to NAT
privateSubnets.forEach((subnet, i) => {
const nat = natGateways[args.singleNatGateway ? 0 : i]
const rt = new aws.ec2.RouteTable(`${name}-private-rt-${i}`, {
vpcId: vpc.id,
routes: [{ cidrBlock: '0.0.0.0/0', natGatewayId: nat.id }],
tags: { ...commonTags, Name: `${name}-private-rt-${i}` },
}, { parent: this })
new aws.ec2.RouteTableAssociation(`${name}-private-rta-${i}`, {
subnetId: subnet.id,
routeTableId: rt.id,
}, { parent: this })
})
}
// Public route table
const publicRt = new aws.ec2.RouteTable(`${name}-public-rt`, {
vpcId: vpc.id,
routes: [{ cidrBlock: '0.0.0.0/0', gatewayId: igw.id }],
tags: { ...commonTags, Name: `${name}-public-rt` },
}, { parent: this })
publicSubnets.forEach((subnet, i) => {
new aws.ec2.RouteTableAssociation(`${name}-public-rta-${i}`, {
subnetId: subnet.id,
routeTableId: publicRt.id,
}, { parent: this })
})
this.vpcId = vpc.id
this.privateSubnetIds = pulumi.output(privateSubnets.map(s => s.id))
this.publicSubnetIds = pulumi.output(publicSubnets.map(s => s.id))
this.registerOutputs({
vpcId: this.vpcId,
privateSubnetIds: this.privateSubnetIds,
publicSubnetIds: this.publicSubnetIds,
})
}
}
Stack Entry Point with StackReference
// index.ts — stack composition
import * as pulumi from '@pulumi/pulumi'
import * as aws from '@pulumi/aws'
import { Vpc } from './components/vpc'
import { loadConfig } from './config'
const cfg = loadConfig()
const stack = pulumi.getStack()
// Create VPC
const vpc = new Vpc(`${stack}-vpc`, {
cidrBlock: cfg.vpcCidr,
availabilityZones: cfg.availabilityZones,
enableNatGateway: cfg.enableNatGateway,
singleNatGateway: cfg.environment !== 'production',
tags: {
Environment: cfg.environment,
Team: 'platform',
},
})
// Reference shared infrastructure stack (DNS, certificates)
const sharedStack = new pulumi.StackReference(`myorg/shared-infra/${cfg.environment}`)
const hostedZoneId = sharedStack.getOutput('hostedZoneId')
const wildcardCertArn = sharedStack.getOutput('wildcardCertArn')
// ECS Cluster
const cluster = new aws.ecs.Cluster(`${stack}-cluster`, {
name: `${stack}-cluster`,
settings: [{ name: 'containerInsights', value: 'enabled' }],
})
// Export values for other stacks to consume
export const vpcId = vpc.vpcId
export const privateSubnetIds = vpc.privateSubnetIds
export const publicSubnetIds = vpc.publicSubnetIds
export const clusterArn = cluster.arn
// Construct-level Output composition
export const clusterInfo = pulumi.all([cluster.name, cluster.arn]).apply(
([name, arn]) => ({ name, arn })
)
Unit Testing with Mocks
// tests/vpc.test.ts
import * as pulumi from '@pulumi/pulumi'
// Set up mocks BEFORE importing any resources
pulumi.runtime.setMocks({
newResource(args: pulumi.runtime.MockResourceArgs) {
return {
id: `${args.name}-id`,
state: {
...args.inputs,
// Simulate outputs that AWS would set
arn: `arn:aws:ec2:us-east-1:123456789:vpc/${args.name}-id`,
},
}
},
call(args: pulumi.runtime.MockCallArgs) {
return args.inputs
},
})
import * as aws from '@pulumi/aws'
import { Vpc } from '../components/vpc'
describe('Vpc component', () => {
it('should create VPC with correct CIDR', async () => {
const vpc = new Vpc('test', {
cidrBlock: '10.0.0.0/16',
availabilityZones: ['us-east-1a', 'us-east-1b'],
enableNatGateway: false,
})
const vpcId = await new Promise<string>(resolve =>
vpc.vpcId.apply(id => resolve(id))
)
expect(vpcId).toBeDefined()
expect(vpcId).toContain('vpc')
})
it('should create correct number of subnets', async () => {
const vpc = new Vpc('test', {
cidrBlock: '10.0.0.0/16',
availabilityZones: ['us-east-1a', 'us-east-1b', 'us-east-1c'],
enableNatGateway: false,
})
const counts = await new Promise<{private: number, public: number}>(resolve => {
pulumi.all([vpc.privateSubnetIds, vpc.publicSubnetIds]).apply(
([priv, pub]) => resolve({ private: priv.length, public: pub.length })
)
})
expect(counts.private).toBe(3)
expect(counts.public).toBe(3)
})
})
Pulumi ESC for Secrets
# esc/environments/production.yaml
# Pulumi ESC environment — accessed by `pulumi env run production`
imports:
- aws-oidc-production # Provides AWS credentials via OIDC
values:
aws:
region: us-east-1
pulumiConfig:
aws:region: ${aws.region}
myapp:environment: production
myapp:domainName: myapp.com
environmentVariables:
AWS_DEFAULT_REGION: ${aws.region}
DATABASE_URL:
fn::secret: ${database.connectionString}
API_KEY:
fn::secret: ${secrets.apiKey}
# Dynamic secrets from AWS Secrets Manager
database:
fn::aws:secrets:
secretId: production/myapp/database
property: connectionString
secrets:
fn::aws:secrets:
secretId: production/myapp/secrets
# Use ESC environment for deployment
pulumi env run myorg/production -- pulumi up --stack production
# Or set as default for stack
pulumi config env add myorg/production --stack production
For the Terraform alternative that uses HCL instead of TypeScript, see the Kubernetes guide for the infrastructure Pulumi typically provisions. For the GitOps workflow that deploys applications onto Pulumi-provisioned clusters, the GitOps and ArgoCD guide covers continuous delivery patterns. The Claude Skills 360 bundle includes Pulumi skill sets covering ComponentResources, StackReferences, and infrastructure testing. Start with the free tier to try Pulumi component generation.