- Introduction
- Pulumi Core Concepts
- Comparing IaC Tools: Pulumi vs Terraform vs CDK
- Environment Setup and Project Initialization
- Building AWS Infrastructure in Practice
- Stack Management and Environment Separation
- Putting the Automation API to Work
- Testing Strategy
- CI/CD Pipeline Integration
- Troubleshooting
- Production Checklist
- Failure Cases and Recovery Procedures
- References

Introduction
Infrastructure as Code (IaC) is no longer optional. As of 2025-2026, leading organizations have gone past merely provisioning infrastructure and now treat it like software — applying every software-engineering practice to it, from testing and version control to code review and CI/CD pipelines.
Existing IaC tools, though — Terraform's HCL (HashiCorp Configuration Language) in particular — are not general-purpose programming languages. Try to implement complex conditional branching, iteration logic, type safety, or unit tests and you run into HCL's limits. Pulumi solves this by letting you define infrastructure in general-purpose languages such as TypeScript, Python, Go, C#, and Java.
This article covers everything you need in practice, centered on TypeScript, from Pulumi's core concepts through running it in production. We will work through the comparison with Terraform, building AWS infrastructure, stack management, the Automation API, testing strategy, CI/CD integration, and troubleshooting, with code examples throughout.
Pulumi Core Concepts
Here are the core concepts you have to understand before using Pulumi.
Project
A project is a directory that holds a Pulumi program. The Pulumi.yaml file defines the project root and specifies the project name and the runtime to use (nodejs, python, go, and so on).
Stack
A stack is an independent instance of a project. When you deploy the same program to different environments such as dev, staging, and production, you manage each one as a stack. Every stack has its own configuration values and state.
Resource
A resource is the basic unit of cloud infrastructure. S3 buckets, EC2 instances, VPCs — all of them are resources. In Pulumi they are expressed as instances of TypeScript classes.
State
Pulumi tracks the current state of the deployed resources. State can be stored in Pulumi Cloud (the default), AWS S3, Azure Blob Storage, Google Cloud Storage, the local filesystem, and elsewhere.
Provider
A provider is a plugin that talks to a particular cloud service. There are more than 150 providers, covering AWS, GCP, Azure, Kubernetes, and more.
Output and Input
The properties of a Pulumi resource come back as the Output<T> type. This expresses their asynchronous nature: the value is unknown until the resource is actually created. When you pass such a value to another resource, it is received as the Input<T> type.
import * as aws from '@pulumi/aws'
// Create an S3 bucket
const bucket = new aws.s3.Bucket('my-bucket', {
website: {
indexDocument: 'index.html',
},
})
// bucket.id has the type Output<string>
// It can be passed straight into another resource as an Input
const bucketPolicy = new aws.s3.BucketPolicy('my-bucket-policy', {
bucket: bucket.id, // Output<string> -> Input<string>, converted automatically
policy: bucket.arn.apply((arn) =>
JSON.stringify({
Version: '2012-10-17',
Statement: [
{
Effect: 'Allow',
Principal: '*',
Action: 's3:GetObject',
Resource: `${arn}/*`,
},
],
})
),
})
// Exporting an Output value surfaces it as a stack output
export const bucketName = bucket.id
export const websiteUrl = bucket.websiteEndpoint
Comparing IaC Tools: Pulumi vs Terraform vs CDK
Here is a comparison of the three major IaC tools from several angles. Use it as a reference for picking the tool that fits your team's stack and requirements.
| Item | Pulumi | Terraform | AWS CDK |
|---|---|---|---|
| Language | TypeScript, Python, Go, C#, Java, YAML | HCL (DSL) | TypeScript, Python, Java, C#, Go |
| Multi-cloud | 150+ providers: AWS, GCP, Azure, K8s, etc. | Thousands of official/community providers | AWS only |
| State management | Pulumi Cloud, S3, GCS, Azure Blob, local | Terraform Cloud, S3, GCS, local, etc. | Delegated to CloudFormation |
| Testing | Standard test frameworks (Jest, Mocha) | terraform test (HCL-based) | Standard test frameworks (Jest, etc.) |
| Type safety | TypeScript static type checking | Limited (HCL variable types) | TypeScript static type checking |
| IDE support | VS Code IntelliSense, autocomplete | Needs an HCL plugin | VS Code IntelliSense, autocomplete |
| Learning curve | Low if you have programming experience | You have to learn HCL | Needs AWS plus programming knowledge |
| State locking | Built in (Pulumi Cloud), S3 DynamoDB | S3 + DynamoDB, provided by Cloud | Managed by CloudFormation itself |
| Drift detection | pulumi refresh | terraform plan | CloudFormation drift detection |
| Secret management | Built-in encryption, ESC support | Needs a Vault integration | Secrets Manager/SSM integration |
| Automation API | Programmatic execution supported | Limited (CLI wrapper) | Limited |
| Community/ecosystem | Growing (GitHub 22k+ stars) | Very mature (GitHub 43k+ stars) | Within the AWS ecosystem |
| License | Apache 2.0 (open source) | BSL (Business Source License) | Apache 2.0 (open source) |
When Should You Choose Pulumi?
- Developer-centric teams: when you want to manage infrastructure in a language you already use, such as TypeScript or Python
- When you need complex logic: when conditional resource creation, dynamic configuration, and involved transformations come up often
- Test-driven infrastructure: when you want to test infrastructure with existing frameworks such as Jest or Mocha
- Putting the Automation API to work: when platform engineering needs to expose infrastructure provisioning as an API
- When built-in secret management matters: when you want to handle secrets safely without a separate tool
When Should You Stay on Terraform?
- Large-scale multi-cloud: when you need an ecosystem of thousands of providers
- Operations-centric teams: when you are an SRE/infrastructure team focused on operations rather than development
- An existing Terraform codebase: when a large body of HCL already exists and migration would be expensive
- tf2pulumi: a tool for migrating from Terraform to Pulumi does exist, but a large-scale switch deserves careful judgment
Environment Setup and Project Initialization
Installing the Pulumi CLI
# macOS
brew install pulumi/tap/pulumi
# Linux (curl)
curl -fsSL https://get.pulumi.com | sh
# Windows (Chocolatey)
choco install pulumi
# Confirm the installation
pulumi version
# v3.x.x
# Check Node.js (required when using TypeScript)
node --version
# v20.x.x or later recommended
# Log in to Pulumi (using Pulumi Cloud)
pulumi login
# Or use an S3 backend
pulumi login s3://my-pulumi-state-bucket
# Use the local filesystem
pulumi login --local
Creating a New Project
# Create a new directory
mkdir my-infra && cd my-infra
# Initialize the project from the AWS TypeScript template
pulumi new aws-typescript
# Configure it at the interactive prompt
# project name: my-infra
# project description: Production infrastructure
# stack name: dev
# aws:region: ap-northeast-2
Let us look at the file structure created after initialization.
my-infra/
├── Pulumi.yaml # Project metadata
├── Pulumi.dev.yaml # dev stack configuration
├── index.ts # Main program
├── package.json # npm dependencies
└── tsconfig.json # TypeScript configuration
The contents of the Pulumi.yaml file look like this.
name: my-infra
runtime:
name: nodejs
options:
typescript: true
description: Production infrastructure
config:
pulumi:tags:
value:
pulumi:template: aws-typescript
Here is an example of the Pulumi.dev.yaml stack configuration file.
config:
aws:region: ap-northeast-2
my-infra:environment: dev
my-infra:dbPassword:
secure: AAABADEFaBCDeFgHiJkLmNoPqRsTuVwXyZ== # Encrypted secret
Building AWS Infrastructure in Practice
Let us build AWS infrastructure in TypeScript that you could genuinely run in production.
VPC and Network Configuration
import * as pulumi from '@pulumi/pulumi'
import * as aws from '@pulumi/aws'
import * as awsx from '@pulumi/awsx'
const config = new pulumi.Config()
const environment = config.require('environment')
// Create a VPC using awsx (a high-level abstraction)
const vpc = new awsx.ec2.Vpc(`${environment}-vpc`, {
cidrBlock: '10.0.0.0/16',
numberOfAvailabilityZones: 3,
subnetStrategy: awsx.ec2.SubnetAllocationStrategy.Auto,
enableDnsHostnames: true,
enableDnsSupport: true,
subnetSpecs: [
{
type: awsx.ec2.SubnetType.Public,
name: 'public',
cidrMask: 24,
},
{
type: awsx.ec2.SubnetType.Private,
name: 'private',
cidrMask: 24,
},
{
type: awsx.ec2.SubnetType.Isolated,
name: 'isolated',
cidrMask: 24,
},
],
tags: {
Environment: environment,
ManagedBy: 'pulumi',
},
})
// Create security groups
const albSecurityGroup = new aws.ec2.SecurityGroup(`${environment}-alb-sg`, {
vpcId: vpc.vpcId,
description: 'Security group for ALB',
ingress: [
{
protocol: 'tcp',
fromPort: 80,
toPort: 80,
cidrBlocks: ['0.0.0.0/0'],
description: 'HTTP',
},
{
protocol: 'tcp',
fromPort: 443,
toPort: 443,
cidrBlocks: ['0.0.0.0/0'],
description: 'HTTPS',
},
],
egress: [
{
protocol: '-1',
fromPort: 0,
toPort: 0,
cidrBlocks: ['0.0.0.0/0'],
description: 'Allow all outbound',
},
],
tags: {
Name: `${environment}-alb-sg`,
Environment: environment,
},
})
export const vpcId = vpc.vpcId
export const publicSubnetIds = vpc.publicSubnetIds
export const privateSubnetIds = vpc.privateSubnetIds
Deploying an ECS Fargate Service
import * as aws from '@pulumi/aws'
import * as awsx from '@pulumi/awsx'
import * as pulumi from '@pulumi/pulumi'
const config = new pulumi.Config()
const environment = config.require('environment')
const containerPort = config.getNumber('containerPort') || 3000
const cpu = config.getNumber('cpu') || 256
const memory = config.getNumber('memory') || 512
const desiredCount = config.getNumber('desiredCount') || 2
// Create an ECR repository
const repo = new awsx.ecr.Repository(`${environment}-app-repo`, {
forceDelete: environment !== 'production',
lifecyclePolicy: {
rules: [
{
description: 'Keep last 10 images',
maximumNumberOfImages: 10,
tagStatus: 'any',
},
],
},
})
// Build and push the Docker image
const image = new awsx.ecr.Image(`${environment}-app-image`, {
repositoryUrl: repo.url,
context: '../app',
platform: 'linux/amd64',
})
// Create an ECS cluster
const cluster = new aws.ecs.Cluster(`${environment}-cluster`, {
settings: [
{
name: 'containerInsights',
value: 'enabled',
},
],
tags: {
Environment: environment,
ManagedBy: 'pulumi',
},
})
// ALB + ECS Fargate service (a high-level awsx component)
const service = new awsx.ecs.FargateService(`${environment}-service`, {
cluster: cluster.arn,
desiredCount: desiredCount,
networkConfiguration: {
subnets: vpc.privateSubnetIds,
securityGroups: [albSecurityGroup.id],
assignPublicIp: false,
},
taskDefinitionArgs: {
container: {
name: 'app',
image: image.imageUri,
cpu: cpu,
memory: memory,
essential: true,
portMappings: [
{
containerPort: containerPort,
targetGroup: loadBalancer.defaultTargetGroup,
},
],
environment: [
{ name: 'NODE_ENV', value: environment },
{ name: 'PORT', value: String(containerPort) },
],
logConfiguration: {
logDriver: 'awslogs',
options: {
'awslogs-group': `/ecs/${environment}-app`,
'awslogs-region': aws.config.region!,
'awslogs-stream-prefix': 'ecs',
},
},
},
},
tags: {
Environment: environment,
ManagedBy: 'pulumi',
},
})
export const serviceUrl = pulumi.interpolate`http://${loadBalancer.loadBalancer.dnsName}`
Creating an RDS Database
import * as aws from '@pulumi/aws'
import * as pulumi from '@pulumi/pulumi'
import * as random from '@pulumi/random'
const config = new pulumi.Config()
const environment = config.require('environment')
const dbName = config.require('dbName')
// Generate a random password
const dbPassword = new random.RandomPassword(`${environment}-db-password`, {
length: 32,
special: true,
overrideSpecial: '!#$%&*()-_=+[]{}<>:?',
})
// Store the password in Secrets Manager
const dbSecret = new aws.secretsmanager.Secret(`${environment}-db-secret`, {
name: `${environment}/database/master-password`,
tags: { Environment: environment },
})
const dbSecretVersion = new aws.secretsmanager.SecretVersion(`${environment}-db-secret-version`, {
secretId: dbSecret.id,
secretString: pulumi
.all([dbPassword.result])
.apply(([password]) => JSON.stringify({ username: 'admin', password })),
})
// DB subnet group
const dbSubnetGroup = new aws.rds.SubnetGroup(`${environment}-db-subnet`, {
subnetIds: vpc.isolatedSubnetIds,
tags: { Environment: environment },
})
// DB security group
const dbSecurityGroup = new aws.ec2.SecurityGroup(`${environment}-db-sg`, {
vpcId: vpc.vpcId,
description: 'Security group for RDS',
ingress: [
{
protocol: 'tcp',
fromPort: 5432,
toPort: 5432,
securityGroups: [albSecurityGroup.id],
description: 'PostgreSQL from ECS',
},
],
tags: { Name: `${environment}-db-sg`, Environment: environment },
})
// Create the RDS instance
const db = new aws.rds.Instance(`${environment}-postgres`, {
engine: 'postgres',
engineVersion: '16.4',
instanceClass: environment === 'production' ? 'db.r6g.large' : 'db.t4g.micro',
allocatedStorage: 20,
maxAllocatedStorage: environment === 'production' ? 100 : 50,
dbName: dbName,
username: 'admin',
password: dbPassword.result,
dbSubnetGroupName: dbSubnetGroup.name,
vpcSecurityGroupIds: [dbSecurityGroup.id],
multiAz: environment === 'production',
backupRetentionPeriod: environment === 'production' ? 14 : 1,
deletionProtection: environment === 'production',
skipFinalSnapshot: environment !== 'production',
finalSnapshotIdentifier:
environment === 'production' ? `${environment}-final-snapshot` : undefined,
storageEncrypted: true,
performanceInsightsEnabled: environment === 'production',
tags: {
Environment: environment,
ManagedBy: 'pulumi',
},
})
export const dbEndpoint = db.endpoint
export const dbSecretArn = dbSecret.arn
What stands out in the code above is that it uses TypeScript conditional expressions to apply different settings per environment quite naturally. In HCL you would have to work within the limited syntax of count, for_each, and ternaries; in Pulumi you can use ordinary programming logic as-is.
Stack Management and Environment Separation
Creating and Switching Stacks
# Create a new stack
pulumi stack init staging
pulumi stack init production
# List the stacks
pulumi stack ls
# NAME LAST UPDATE RESOURCE COUNT URL
# dev* 2 minutes 15 https://app.pulumi.com/...
# staging n/a n/a https://app.pulumi.com/...
# production n/a n/a https://app.pulumi.com/...
# Switch stacks
pulumi stack select staging
# Per-stack configuration
pulumi config set environment staging
pulumi config set aws:region ap-northeast-2
pulumi config set desiredCount 2
pulumi config set --secret dbPassword 'super-secret-password'
# Show all configuration
pulumi config
# KEY VALUE
# aws:region ap-northeast-2
# dbPassword [secret]
# desiredCount 2
# environment staging
Stack References (Cross-Stack Reference)
On large projects you split infrastructure across several projects and connect them through stack references.
import * as pulumi from '@pulumi/pulumi'
// Reference the outputs of the network stack
const networkStack = new pulumi.StackReference('organization/network-infra/production')
// Fetch output values from another stack
const vpcId = networkStack.getOutput('vpcId')
const privateSubnetIds = networkStack.getOutput('privateSubnetIds')
// Use those values in the current stack
const service = new aws.ecs.Service('my-service', {
networkConfiguration: {
subnets: privateSubnetIds.apply((ids) => ids as string[]),
// ...
},
})
Self-Managed Backend (S3)
To use S3 as the backend instead of Pulumi Cloud, configure it as follows.
# Create the S3 bucket (AWS CLI)
aws s3 mb s3://my-company-pulumi-state --region ap-northeast-2
# Enable bucket versioning (protects the state file)
aws s3api put-bucket-versioning \
--bucket my-company-pulumi-state \
--versioning-configuration Status=Enabled
# Enable server-side encryption
aws s3api put-bucket-encryption \
--bucket my-company-pulumi-state \
--server-side-encryption-configuration '{
"Rules": [{"ApplyServerSideEncryptionByDefault": {"SSEAlgorithm": "aws:kms"}}]
}'
# Point the Pulumi backend at S3
pulumi login s3://my-company-pulumi-state
# Configure secret encryption with a KMS key
pulumi stack init production \
--secrets-provider="awskms://alias/pulumi-secrets?region=ap-northeast-2"
When you use the S3 backend, state locking is enabled by default, which stops several processes from modifying the state at once. To upgrade an existing DIY backend to project-scoped stacks, you can use the pulumi state upgrade command.
Pulumi ESC (Environments, Secrets, and Configuration)
Pulumi ESC is a feature for managing per-environment secrets and configuration centrally.
# Example Pulumi ESC environment definition (my-org/production.yaml)
imports:
- my-org/base-config # Inherit the base configuration
values:
aws:
login:
fn::open::aws-login:
oidc:
roleArn: arn:aws:iam::123456789012:role/pulumi-esc-role
sessionName: pulumi-esc-session
environmentVariables:
AWS_ACCESS_KEY_ID: ${aws.login.accessKeyId}
AWS_SECRET_ACCESS_KEY: ${aws.login.secretAccessKey}
AWS_SESSION_TOKEN: ${aws.login.sessionToken}
pulumiConfig:
aws:region: ap-northeast-2
environment: production
dbInstanceClass: db.r6g.large
secrets:
fn::open::aws-secrets:
region: ap-northeast-2
login: ${aws.login}
get:
db-password:
secretId: production/database/master-password
With ESC you can pull secrets dynamically from AWS OIDC, Azure OIDC, Google Cloud OIDC, HashiCorp Vault, AWS Secrets Manager, and other sources.
Putting the Automation API to Work
The Pulumi Automation API is one of Pulumi's strongest differentiators. It lets you run Pulumi operations programmatically without the CLI, which makes it ideal for platform engineering or for building a self-service infrastructure portal.
An Inline Program Example
import { InlineProgramArgs, LocalWorkspace } from '@pulumi/pulumi/automation'
import * as aws from '@pulumi/aws'
// Define the Pulumi program inline
const pulumiProgram = async () => {
const bucket = new aws.s3.Bucket('auto-bucket', {
website: {
indexDocument: 'index.html',
},
})
return {
bucketName: bucket.id,
websiteUrl: bucket.websiteEndpoint,
}
}
async function deployInfrastructure(stackName: string, region: string) {
const args: InlineProgramArgs = {
stackName,
projectName: 'auto-deploy',
program: pulumiProgram,
}
// Create or select the stack
const stack = await LocalWorkspace.createOrSelectStack(args)
// Stack configuration
await stack.setConfig('aws:region', { value: region })
console.log('Running pulumi preview...')
const previewResult = await stack.preview({ onOutput: console.log })
console.log(`Preview: ${previewResult.changeSummary}`)
console.log('Running pulumi up...')
const upResult = await stack.up({ onOutput: console.log })
console.log(`Update summary: ${JSON.stringify(upResult.summary)}`)
console.log(`Outputs: ${JSON.stringify(upResult.outputs)}`)
return upResult.outputs
}
async function destroyInfrastructure(stackName: string) {
const args: InlineProgramArgs = {
stackName,
projectName: 'auto-deploy',
program: pulumiProgram,
}
const stack = await LocalWorkspace.createOrSelectStack(args)
console.log('Running pulumi destroy...')
await stack.destroy({ onOutput: console.log })
console.log('Removing stack...')
await stack.workspace.removeStack(stackName)
}
// Usage example
;(async () => {
try {
const outputs = await deployInfrastructure('dev', 'ap-northeast-2')
console.log(`Website URL: ${outputs.websiteUrl.value}`)
} catch (err) {
console.error(`Error: ${err}`)
process.exit(1)
}
})()
Exposing Infrastructure Through an HTTP API
Combine the Automation API with Express.js and you can expose infrastructure provisioning as a REST API.
import express from 'express'
import { InlineProgramArgs, LocalWorkspace } from '@pulumi/pulumi/automation'
import * as aws from '@pulumi/aws'
const app = express()
app.use(express.json())
// POST /api/environments - create a new environment
app.post('/api/environments', async (req, res) => {
const { name, region, instanceType } = req.body
try {
const program = async () => {
const vpc = new aws.ec2.Vpc(`${name}-vpc`, {
cidrBlock: '10.0.0.0/16',
tags: { Name: `${name}-vpc` },
})
const subnet = new aws.ec2.Subnet(`${name}-subnet`, {
vpcId: vpc.id,
cidrBlock: '10.0.1.0/24',
tags: { Name: `${name}-subnet` },
})
return { vpcId: vpc.id, subnetId: subnet.id }
}
const stack = await LocalWorkspace.createOrSelectStack({
stackName: name,
projectName: 'self-service-infra',
program,
})
await stack.setConfig('aws:region', { value: region || 'ap-northeast-2' })
const result = await stack.up({ onOutput: console.log })
res.json({
status: 'deployed',
outputs: result.outputs,
summary: result.summary,
})
} catch (error: any) {
res.status(500).json({ error: error.message })
}
})
// DELETE /api/environments/:name - delete an environment
app.delete('/api/environments/:name', async (req, res) => {
const { name } = req.params
try {
const stack = await LocalWorkspace.selectStack({
stackName: name,
projectName: 'self-service-infra',
program: async () => ({}),
})
await stack.destroy({ onOutput: console.log })
await stack.workspace.removeStack(name)
res.json({ status: 'destroyed' })
} catch (error: any) {
res.status(500).json({ error: error.message })
}
})
// GET /api/environments/:name - look up the environment's state
app.get('/api/environments/:name', async (req, res) => {
const { name } = req.params
try {
const stack = await LocalWorkspace.selectStack({
stackName: name,
projectName: 'self-service-infra',
program: async () => ({}),
})
const outputs = await stack.outputs()
const info = await stack.info()
res.json({ stack: name, outputs, lastUpdate: info })
} catch (error: any) {
res.status(404).json({ error: `Stack ${name} not found` })
}
})
app.listen(3000, () => console.log('Infra API running on :3000'))
This pattern is extremely useful when building an internal developer platform (IDP). You can create a self-service portal where developers provision infrastructure themselves.
Testing Strategy
One of Pulumi's great advantages is that you can test infrastructure code with standard test frameworks. Broadly, this splits into unit tests and integration tests.
Unit Tests (Jest)
In a unit test you mock the Pulumi engine and verify the infrastructure logic without any real cloud resources.
// __tests__/infra.test.ts
import * as pulumi from '@pulumi/pulumi'
// Mock the Pulumi runtime - this must be set up before the imports
pulumi.runtime.setMocks({
newResource: (args: pulumi.runtime.MockResourceArgs): { id: string; state: any } => {
// Mocked responses per resource type
switch (args.type) {
case 'aws:s3/bucket:Bucket':
return {
id: `${args.name}-id`,
state: {
...args.inputs,
arn: `arn:aws:s3:::${args.name}`,
bucket: args.name,
websiteEndpoint: `${args.name}.s3-website.ap-northeast-2.amazonaws.com`,
},
}
case 'aws:ec2/securityGroup:SecurityGroup':
return {
id: `sg-${args.name}`,
state: {
...args.inputs,
arn: `arn:aws:ec2:ap-northeast-2:123456789012:security-group/sg-${args.name}`,
},
}
default:
return {
id: `${args.name}-id`,
state: args.inputs,
}
}
},
call: (args: pulumi.runtime.MockCallArgs) => {
return args.inputs
},
})
// Import the infrastructure code under test (import after the mocks are set up)
import { bucket, securityGroup } from '../index'
describe('Infrastructure Tests', () => {
test('the S3 bucket should have website configuration', async () => {
const websiteConfig = await new Promise((resolve) =>
bucket.website.apply((website) => resolve(website))
)
expect(websiteConfig).toBeDefined()
})
test('the S3 bucket name should include the environment prefix', async () => {
const bucketName = await new Promise((resolve) => bucket.id.apply((name) => resolve(name)))
expect(bucketName).toContain('dev')
})
test('the security group should allow port 443', async () => {
const ingress = await new Promise((resolve) =>
securityGroup.ingress.apply((rules) => resolve(rules))
)
const httpsRule = (ingress as any[]).find((r: any) => r.fromPort === 443)
expect(httpsRule).toBeDefined()
expect(httpsRule.protocol).toBe('tcp')
})
test('the security group should not open SSH (22) to 0.0.0.0/0', async () => {
const ingress = await new Promise((resolve) =>
securityGroup.ingress.apply((rules) => resolve(rules))
)
const sshRule = (ingress as any[]).find(
(r: any) => r.fromPort === 22 && r.cidrBlocks?.includes('0.0.0.0/0')
)
expect(sshRule).toBeUndefined()
})
})
Write the Jest configuration file as well.
// jest.config.ts
import type { Config } from 'jest'
const config: Config = {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['**/__tests__/**/*.test.ts'],
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'],
transform: {
'^.+\\.tsx?$': 'ts-jest',
},
// Prevents Pulumi Output timeouts
testTimeout: 30000,
}
export default config
Policy as Code (CrossGuard)
With Pulumi CrossGuard you can define policy as code and have it verified automatically at deployment time.
// policy-pack/index.ts
import * as policy from '@pulumi/policy'
new policy.PolicyPack('security-policies', {
policies: [
{
name: 's3-no-public-read',
description: 'S3 buckets must not grant public read access',
enforcementLevel: 'mandatory',
validateResource: policy.validateResourceOfType(
'aws:s3/bucket:Bucket',
(bucket, args, reportViolation) => {
if (bucket.acl === 'public-read' || bucket.acl === 'public-read-write') {
reportViolation('This S3 bucket has a public ACL set.')
}
}
),
},
{
name: 'rds-encryption-required',
description: 'RDS instances must be encrypted',
enforcementLevel: 'mandatory',
validateResource: policy.validateResourceOfType(
'aws:rds/instance:Instance',
(instance, args, reportViolation) => {
if (!instance.storageEncrypted) {
reportViolation('Storage encryption is not enabled on this RDS instance.')
}
}
),
},
{
name: 'ec2-no-public-ip',
description: 'EC2 instances must not be assigned a public IP',
enforcementLevel: 'advisory',
validateResource: policy.validateResourceOfType(
'aws:ec2/instance:Instance',
(instance, args, reportViolation) => {
if (instance.associatePublicIpAddress) {
reportViolation('This EC2 instance has a public IP assigned.')
}
}
),
},
],
})
CI/CD Pipeline Integration
GitHub Actions Workflow
Pulumi's official GitHub Actions let you build a PR-based workflow for infrastructure changes.
# .github/workflows/pulumi-preview.yml
name: Pulumi Preview
on:
pull_request:
branches: [main]
paths:
- 'infra/**'
env:
PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
AWS_REGION: ap-northeast-2
jobs:
preview:
name: Pulumi Preview
runs-on: ubuntu-latest
defaults:
run:
working-directory: infra
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: infra/package-lock.json
# A plugin cache speeds up CI
- uses: actions/cache@v4
with:
path: |
~/.pulumi/plugins
~/.pulumi/policies
key: ${{ runner.os }}-pulumi-${{ hashFiles('infra/package-lock.json') }}
restore-keys: |
${{ runner.os }}-pulumi-
- run: npm ci
# Run the unit tests
- name: Run Unit Tests
run: npm test
# Pulumi Preview
- uses: pulumi/actions@v6
with:
command: preview
stack-name: organization/my-infra/staging
work-dir: infra
comment-on-pr: true
comment-on-summary: true
# .github/workflows/pulumi-deploy.yml
name: Pulumi Deploy
on:
push:
branches: [main]
paths:
- 'infra/**'
env:
PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }}
AWS_REGION: ap-northeast-2
jobs:
deploy-staging:
name: Deploy to Staging
runs-on: ubuntu-latest
environment: staging
defaults:
run:
working-directory: infra
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: infra/package-lock.json
- uses: actions/cache@v4
with:
path: |
~/.pulumi/plugins
~/.pulumi/policies
key: ${{ runner.os }}-pulumi-${{ hashFiles('infra/package-lock.json') }}
- run: npm ci
- uses: pulumi/actions@v6
with:
command: up
stack-name: organization/my-infra/staging
work-dir: infra
deploy-production:
name: Deploy to Production
needs: deploy-staging
runs-on: ubuntu-latest
environment: production # GitHub Environment (allows manual approval)
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: infra/package-lock.json
- uses: actions/cache@v4
with:
path: |
~/.pulumi/plugins
~/.pulumi/policies
key: ${{ runner.os }}-pulumi-${{ hashFiles('infra/package-lock.json') }}
- run: npm ci
working-directory: infra
- uses: pulumi/actions@v6
with:
command: up
stack-name: organization/my-infra/production
work-dir: infra
Install the GitHub App and a summary of the resource changes is posted automatically as a comment on the PR, which makes it easy for reviewers to grasp the impact of an infrastructure change.
Troubleshooting
1. Update Conflict (State Conflict)
Symptom: error: the stack is currently locked by 1 lock(s) or conflict: another update is in progress
Cause: this happens when another user is updating the same stack, or when a previous update terminated abnormally.
Fix:
# Cancel the current update (only when nobody else is working on it)
pulumi cancel
# Check the state
pulumi stack --show-urns
2. Interrupted Update
Symptom: error: update interrupted, or resources left in a pending state
Fix:
# Synchronize the state with the cloud provider
pulumi refresh --yes
# Any pending operations are cleaned up automatically
# After that, retry the deployment as normal
pulumi up
3. Resource Already Exists
Symptom: error: resource already exists - a collision with a resource created outside Pulumi
Fix:
# Bring the existing resource into Pulumi state (import)
pulumi import aws:s3/bucket:Bucket my-bucket my-existing-bucket-name
# Or use the import option in code
const existingBucket = new aws.s3.Bucket(
'my-bucket',
{
bucket: 'my-existing-bucket-name',
},
{
import: 'my-existing-bucket-name', // import the existing resource
}
)
4. Problems Accessing Output Values
Symptom: a Calling [toString] on an [Output<T>] warning, or [object Object] in the output
Cause: this happens when you try to use an Output<T> value directly as a string
Fix:
// The wrong way
const url = `http://${bucket.websiteEndpoint}` // prints [object Object]
// The right way 1: the apply method
const url = bucket.websiteEndpoint.apply((ep) => `http://${ep}`)
// The right way 2: the pulumi.interpolate tagged template
const url = pulumi.interpolate`http://${bucket.websiteEndpoint}`
// The right way 3: combining several Outputs with pulumi.all
const combined = pulumi.all([bucket.id, bucket.arn]).apply(([id, arn]) => {
return { id, arn }
})
5. Provider Version Conflicts
Symptom: a failed to load plugin or version mismatch error
Fix:
# Clear the plugin cache
rm -rf ~/.pulumi/plugins
# Reinstall the dependencies
npm install
# Pin a specific provider version
npm install @pulumi/aws@6.x.x --save-exact
# Install the Pulumi plugin
pulumi plugin install resource aws v6.x.x
6. State File Corruption
Symptom: checkpoint file is not valid, or unexpected resource state
Fix:
# Export the state file (backup)
pulumi stack export > state-backup.json
# Verify and edit the state file
# You can remove or repair the problem resource by hand
# Import the repaired state file
pulumi stack import < state-fixed.json
# Or remove a specific resource from the state
pulumi state delete 'urn:pulumi:dev::my-infra::aws:s3/bucket:Bucket::my-bucket'
Production Checklist
Check the following items before adopting Pulumi in production.
State Management
- The state backend has been chosen (Pulumi Cloud or S3/GCS)
- Bucket versioning is enabled when using S3
- Server-side encryption (SSE-KMS) is configured when using S3
- Access is restricted by bucket policy when using S3
- State locking has been verified to work
Secret Management
- A secret provider is configured (Pulumi Cloud, AWS KMS, HashiCorp Vault, etc.)
- The
--secretflag is used for sensitive configuration values - ESC environments are set up (for larger teams)
- OIDC-based dynamic credentials are configured
Code Quality
- Unit tests are written and wired into CI
- A Policy Pack (CrossGuard) is defined
- A code review process is established
- Modularization and the component resource pattern are applied
- TypeScript strict mode is enabled
CI/CD
- A GitHub Actions (or other CI) workflow is configured
-
pulumi previewruns automatically on a PR -
pulumi updeploys automatically on a merge to the main branch - Production deployments require manual approval (GitHub Environments)
- A plugin cache is configured to speed up CI
Operations
- A stack naming convention is established (organization/project/environment)
- A resource tagging policy is defined (Environment, ManagedBy, Owner, etc.)
- Drift is detected by running
pulumi refreshon a schedule - Projects are separated using the StackReference pattern
- The rollback procedure is documented
Security
- The principle of least privilege is applied to IAM
- CI/CD uses OIDC-based authentication (avoiding long-lived credentials)
- Access to the state file is restricted
- Audit logging is enabled
- deletionProtection is set on critical resources
Failure Cases and Recovery Procedures
Case 1: Accidentally Deleting a Production RDS Instance
Situation: someone meant to run pulumi destroy against the staging stack, but ran it while the production stack was selected, and the RDS instance was deleted.
Recovery procedure:
# 1. Check the stack state immediately
pulumi stack select production
pulumi stack --show-urns
# 2. Look for the RDS final snapshot (if deletionProtection was not set)
aws rds describe-db-snapshots \
--db-instance-identifier production-postgres \
--query 'DBSnapshots[*].{ID:DBSnapshotIdentifier,Time:SnapshotCreateTime}' \
--output table
# 3. Restore from the snapshot
aws rds restore-db-instance-from-db-snapshot \
--db-instance-identifier production-postgres-restored \
--db-snapshot-identifier production-final-snapshot
# 4. Import it back into Pulumi state
pulumi import aws:rds/instance:Instance production-postgres production-postgres-restored
Prevention:
// Always enable deletionProtection
const db = new aws.rds.Instance(
'production-postgres',
{
// ... settings ...
deletionProtection: true, // guards against accidental deletion
},
{
protect: true, // protected at the Pulumi level as well
}
)
Case 2: The State File and the Real Resources Diverge (Drift)
Situation: a security group rule was changed by hand in the AWS console, was never reflected in the Pulumi state, and caused a conflict on the next deployment
Recovery procedure:
# 1. Check for drift
pulumi refresh --diff
# Example output:
# ~ aws:ec2/securityGroup:SecurityGroup (update)
# ~ ingress: [
# + { fromPort: 8080, toPort: 8080, ... } # a rule added by hand
# ]
# 2-A. Reflect the real state in the code (apply the manual change to the code)
# After editing the code:
pulumi refresh --yes
pulumi up
# 2-B. Restore the state described by the code (revert the manual change)
pulumi up --yes # running up without refresh restores what the code says
Prevention: set a team rule that forbids manual changes in the AWS console and requires every change to go through code. Detecting drift automatically with AWS Config Rules or Pulumi CrossGuard is another good approach.
Case 3: Concurrent Deployment Conflicts in CI/CD
Situation: two PRs merged at nearly the same moment, two pulumi up runs executed concurrently, and a state conflict resulted
Recovery procedure:
# 1. Resolve the conflict - check the lock first
pulumi cancel # cancel the pending update
# 2. Synchronize the state with refresh
pulumi refresh --yes
# 3. Deploy again
pulumi up --yes
Prevention: use the concurrency setting in GitHub Actions to prevent concurrent deployments.
# .github/workflows/pulumi-deploy.yml
concurrency:
group: pulumi-${{ github.ref }}
cancel-in-progress: false # do not cancel a deployment already in progress
Case 4: Preventing Resource Recreation During a Large Refactor
Situation: after changing a resource's name or structure, Pulumi wants to delete the existing resource and create a new one (replace)
Recovery procedure:
# 1. Review the changes with preview
pulumi preview --diff
# 2. Use aliases when renaming a resource
// Existing code: new aws.s3.Bucket("old-name", {...})
// After the change:
const bucket = new aws.s3.Bucket(
'new-name',
{
// ... settings ...
},
{
aliases: [{ name: 'old-name' }], // declare the previous name as an alias
}
)
# 3. Or rename the resource directly in the state
pulumi state rename 'urn:pulumi:dev::project::aws:s3/bucket:Bucket::old-name' 'new-name'
References
- Pulumi official documentation - Infrastructure as Code - the official documentation covering the concepts and usage of Pulumi IaC end to end.
- Pulumi Automation API documentation - a detailed guide to the Automation API for running Pulumi programmatically.
- Pulumi GitHub Actions integration guide - the official guide to using Pulumi in a CI/CD pipeline.
- Pulumi state and backend management - explains how to manage state, including self-managed backends such as S3 and GCS.
- Pulumi unit testing guide - covers writing unit tests in TypeScript, Python, Go, and other languages.
- Terraform vs Pulumi vs CDK, a 2025 comparison - a comprehensive comparison of the three major IaC tools.
- Pulumi ESC - environments, secrets, and configuration management - an introduction to the centralized secret and environment management solution.
- Pulumi official troubleshooting guide - how to resolve common problems such as state conflicts and interrupted updates.
- Pulumi GitHub Repository - the Pulumi open-source core repository (Apache 2.0 license).
- Pulumi TypeScript/Node.js SDK documentation - the SDK documentation for using Pulumi from TypeScript.