Claude Code for Pulumi: TypeScript Infrastructure as Code — Claude Skills 360 Blog
Blog / Infrastructure / Claude Code for Pulumi: TypeScript Infrastructure as Code
Infrastructure

Claude Code for Pulumi: TypeScript Infrastructure as Code

Published: December 15, 2026
Read time: 9 min read
By: Claude Skills 360

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.

Put these ideas into practice

Claude Skills 360 gives you production-ready skills for everything in this article — and 2,350+ more. Start free or go all-in.

Back to Blog

Get 360 skills free