Skip to content
7 min read·Lesson 7 of 8

Pulumi: Multi-Cloud IaC in TypeScript

Pulumi's model — programs, stacks, state, config, and the differences from CDK that matter for real usage.

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 CDKPulumi
ExecutionTypeScript → CloudFormation → AWS APITypeScript → Pulumi Engine → Cloud API directly
StateCloudFormation manages statePulumi state backend (Pulumi Cloud, S3, Azure Blob, GCS)
ProvidersAWS only (via CloudFormation)100+ providers; any Terraform provider
Drift detectionCloudFormation drift detectionpulumi refresh reconciles state
Import existingcdk importpulumi 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

CDKPulumi
AWS-only or AWS-primaryMulti-cloud (AWS + Azure + GCP + Kubernetes)
Team uses CloudFormation alreadyTeam uses Terraform or wants non-CF state
CDK Pipelines for CodePipeline-native CIPulumi Automation API for custom provisioning
AWS Solutions Constructs ecosystemTerraform provider coverage needed
Extensive AWS-specific L2 abstractionsPure 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.

Key Takeaways

  • Pulumi stores state (like Terraform) and uses real language programs — not CloudFormation synthesis.
  • Resources are objects; outputs are Pulumi.Output<T> that resolve after apply.
  • Pulumi supports AWS, Azure, GCP, Kubernetes, and 100+ providers from the same program.
  • Component Resources are the Pulumi equivalent of CDK constructs — reusable, composable.
  • Pulumi Automation API lets you embed Pulumi deployments inside TypeScript applications.

Test your knowledge

Try exam-style practice questions to reinforce what you've learned.

Practice Questions →