Infrastructure as Code with Pulumi: 5 Patterns to Manage Cloud Resources with TypeScript
Pulumi: Managing Infrastructure with Real Programming Languages
Terraform uses HCL for configuration—when you need loops, conditions, or reuse, you write a bunch of workarounds. Pulumi lets you define infrastructure with real programming languages like TypeScript, Python, and Go. Loops, conditions, functions, classes—all programming capabilities are available. In 2026, Pulumi supports 80+ cloud providers, Pulumi AI auto-generates infrastructure code, and Pulumi Deployments enables GitOps automation.
This article walks through 5 core patterns, covering the full pipeline from project setup → component abstraction → multi-cloud deployment → state management → GitOps.
Core Concepts
| Concept | Description |
|---|---|
| Pulumi | IaC tool for defining infrastructure with programming languages |
| Stack | Different environments of the same project (dev/staging/prod) |
| Resource | Abstract representation of a cloud resource |
| ComponentResource | Custom component encapsulating multiple Resources |
| Provider | Cloud provider plugin (AWS/GCP/Azure/K8s) |
| State | Persistent storage of infrastructure state |
| Output | Async value representing a resource's post-creation properties |
| Config | Stack-level configuration key-value pairs |
Problem Analysis: 5 Challenges in Pulumi IaC
- Learning curve: Mindset shift required when migrating from HCL to TypeScript
- Output handling: Outputs can't be used directly as strings; need to understand async chains
- State management: Recovery from corrupted or conflicting state files
- Component design: How to abstract reusable infrastructure components
- Multi-cloud orchestration: Dependencies and coordination across different cloud provider resources
Step-by-Step: 5 Pulumi IaC Patterns
Pattern 1: Pulumi Project & Basic Resources
// index.ts - Pulumi project entry point
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.get('environment') || 'dev';
const vpcCidr = config.get('vpcCidr') || '10.0.0.0/16';
const vpc = new aws.ec2.Vpc('main-vpc', {
cidrBlock: vpcCidr,
enableDnsHostnames: true,
enableDnsSupport: true,
tags: {
Name: `${environment}-vpc`,
Environment: environment,
},
});
const publicSubnets = [0, 1, 2].map((i) => {
return new aws.ec2.Subnet(`public-${i}`, {
vpcId: vpc.id,
cidrBlock: `10.0.${i}.0/24`,
availabilityZone: aws.getAvailabilityZonesOutput().names[i],
mapPublicIpOnLaunch: true,
tags: { Name: `${environment}-public-${i}`, Environment: environment },
});
});
const privateSubnets = [0, 1, 2].map((i) => {
return new aws.ec2.Subnet(`private-${i}`, {
vpcId: vpc.id,
cidrBlock: `10.0.${i + 10}.0/24`,
availabilityZone: aws.getAvailabilityZonesOutput().names[i],
tags: { Name: `${environment}-private-${i}`, Environment: environment },
});
});
const cluster = new aws.ecs.Cluster('main-cluster', {
tags: { Name: `${environment}-cluster`, Environment: environment },
});
const sg = new aws.ec2.SecurityGroup('app-sg', {
vpcId: vpc.id,
ingress: [
{ protocol: 'tcp', fromPort: 80, toPort: 80, cidrBlocks: ['0.0.0.0/0'] },
{ protocol: 'tcp', fromPort: 443, toPort: 443, cidrBlocks: ['0.0.0.0/0'] },
],
egress: [
{ protocol: '-1', fromPort: 0, toPort: 0, cidrBlocks: ['0.0.0.0/0'] },
],
tags: { Name: `${environment}-app-sg`, Environment: environment },
});
export const vpcId = vpc.id;
export const clusterArn = cluster.arn;
export const securityGroupId = sg.id;
Pattern 2: ComponentResource Component Abstraction
// components/ecs-service.ts
import * as pulumi from '@pulumi/pulumi';
import * as aws from '@pulumi/aws';
interface EcsServiceArgs {
clusterArn: pulumi.Input<string>;
subnets: pulumi.Input<string>[];
securityGroups: pulumi.Input<string>[];
imageName: pulumi.Input<string>;
cpu: number;
memory: number;
port: number;
desiredCount: number;
environment: string;
}
export class EcsService extends pulumi.ComponentResource {
public readonly serviceArn: pulumi.Output<string>;
public readonly albDns: pulumi.Output<string>;
constructor(name: string, args: EcsServiceArgs, opts?: pulumi.ComponentResourceOptions) {
super('custom:ecs:Service', name, {}, opts);
const alb = new aws.lb.LoadBalancer(`${name}-alb`, {
subnets: args.subnets,
securityGroups: args.securityGroups,
internal: false,
loadBalancerType: 'application',
tags: { Name: `${args.environment}-${name}-alb`, Environment: args.environment },
}, { parent: this });
const targetGroup = new aws.lb.TargetGroup(`${name}-tg`, {
port: args.port,
protocol: 'HTTP',
vpcId: args.subnets[0],
targetType: 'ip',
healthCheck: {
path: '/health',
interval: 30,
timeout: 5,
healthyThreshold: 2,
unhealthyThreshold: 3,
},
tags: { Name: `${args.environment}-${name}-tg`, Environment: args.environment },
}, { parent: this });
const listener = new aws.lb.Listener(`${name}-listener`, {
loadBalancerArn: alb.arn,
port: 80,
protocol: 'HTTP',
defaultActions: [{
type: 'forward',
targetGroupArn: targetGroup.arn,
}],
}, { parent: this });
const taskDefinition = new aws.ecs.TaskDefinition(`${name}-task`, {
family: `${args.environment}-${name}`,
networkMode: 'awsvpc',
requiresCompatibilities: ['FARGATE'],
cpu: args.cpu.toString(),
memory: args.memory.toString(),
containerDefinitions: pulumi.jsonStringify([{
name: name,
image: args.imageName,
essential: true,
portMappings: [{ containerPort: args.port, protocol: 'tcp' }],
logConfiguration: {
logDriver: 'awslogs',
options: {
'awslogs-group': `/ecs/${args.environment}-${name}`,
'awslogs-region': 'us-east-1',
'awslogs-stream-prefix': 'ecs',
},
},
}]),
}, { parent: this });
const service = new aws.ecs.Service(`${name}-svc`, {
cluster: args.clusterArn,
desiredCount: args.desiredCount,
launchType: 'FARGATE',
taskDefinition: taskDefinition.arn,
networkConfiguration: {
awsvpcConfiguration: {
subnets: args.subnets,
securityGroups: args.securityGroups,
assignPublicIp: true,
},
},
loadBalancers: [{
targetGroupArn: targetGroup.arn,
containerName: name,
containerPort: args.port,
}],
}, { parent: this });
this.serviceArn = service.id;
this.albDns = alb.dnsName;
this.registerOutputs({
serviceArn: this.serviceArn,
albDns: this.albDns,
});
}
}
Pattern 3: Multi-Cloud Deployment
// multi-cloud.ts
import * as pulumi from '@pulumi/pulumi';
import * as aws from '@pulumi/aws';
import * as gcp from '@pulumi/gcp';
const config = new pulumi.Config();
const cloud = config.get('cloud') || 'aws';
function createAwsInfrastructure() {
const bucket = new aws.s3.Bucket('data-bucket', {
forceDestroy: true,
tags: { Name: 'data-bucket' },
});
const db = new aws.rds.Instance('database', {
engine: 'postgres',
engineVersion: '16.1',
instanceClass: 'db.t3.micro',
allocatedStorage: 20,
username: 'admin',
password: config.requireSecret('dbPassword'),
skipFinalSnapshot: true,
});
return { bucketArn: bucket.arn, dbEndpoint: db.endpoint };
}
function createGcpInfrastructure() {
const bucket = new gcp.storage.Bucket('data-bucket', {
location: 'US',
forceDestroy: true,
});
const db = new gcp.sql.DatabaseInstance('database', {
databaseVersion: 'POSTGRES_16',
settings: { tier: 'db-f1-micro' },
deletionProtection: false,
});
return { bucketName: bucket.name, dbConnection: db.connectionName };
}
const infra = cloud === 'aws' ? createAwsInfrastructure() : createGcpInfrastructure();
export const bucketOutput = 'bucketArn' in infra ? infra.bucketArn : infra.bucketName;
export const dbOutput = 'dbEndpoint' in infra ? infra.dbEndpoint : infra.dbConnection;
Pattern 4: State Management & Disaster Recovery
// pulumi.state-config.ts
// Use S3 backend for state storage
const backendConfig = {
url: 's3://pulumi-state-bucket/my-project',
};
// pulumi stack init my-project-dev
// pulumi config set aws:region us-east-1
// pulumi up
# State lock conflict resolution
pulumi stack import --file backup-state.json
# State file backup and recovery
pulumi stack export --file state-backup-$(date +%Y%m%d).json
# Force unlock (use with caution)
pulumi stack unlock
# Refresh state to sync with actual resources
pulumi refresh
# Destroy all resources
pulumi destroy
// State drift detection
import * as pulumi from '@pulumi/pulumi';
pulumi.runtime.setOptions({
stackTransform: (args) => {
args.resource.options.protect = true;
return Promise.resolve(args);
},
});
Pattern 5: Pulumi GitOps & Automation
# .github/workflows/pulumi-deploy.yml
name: Pulumi Deploy
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
preview:
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v4
- uses: pulumi/actions@v5
with:
command: preview
stack-name: my-project-staging
comment-on-pr: true
env:
PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_TOKEN }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_KEY }}
deploy:
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
needs: []
steps:
- uses: actions/checkout@v4
- uses: pulumi/actions@v5
with:
command: up
stack-name: my-project-prod
skip-install: false
env:
PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_TOKEN }}
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_KEY }}
// Policy as Code - Compliance checks
import * as pulumi from '@pulumi/pulumi';
import { PolicyPack, validateResourceOfType } from '@pulumi/policy';
new PolicyPack('infra-policies', {
policies: [
{
name: 'no-public-egress',
description: 'Security groups must not allow unrestricted egress',
enforcementLevel: 'mandatory',
validateResource: validateResourceOfType(aws.ec2.SecurityGroup, (sg, args, reportViolation) => {
for (const rule of sg.egress || []) {
if (rule.cidrBlocks?.includes('0.0.0.0/0')) {
reportViolation('Security group allows unrestricted egress');
}
}
}),
},
{
name: 'encryption-required',
description: 'S3 buckets must have encryption enabled',
enforcementLevel: 'mandatory',
validateResource: validateResourceOfType(aws.s3.Bucket, (bucket, args, reportViolation) => {
if (!bucket.serverSideEncryptionConfiguration) {
reportViolation('S3 bucket must have server-side encryption enabled');
}
}),
},
{
name: 'tag-compliance',
description: 'All resources must have required tags',
enforcementLevel: 'advisory',
validateResource: (args, reportViolation) => {
const tags = args.props.tags || {};
if (!tags.Environment) {
reportViolation('Resource missing required tag: Environment');
}
},
},
],
});
Pitfall Guide
Pitfall 1: Output Can't Be Used as String
// ❌ Wrong: Direct Output concatenation
const url = `https://${alb.dnsName}/api`; // Type error
// ✅ Correct: Use pulumi.interpolate or apply
const url = pulumi.interpolate`https://${alb.dnsName}/api`;
// Or
const url = alb.dnsName.apply(dns => `https://${dns}/api`);
Pitfall 2: Resource Dependencies Not Declared
// ❌ Wrong: Implicit dependency, creation order may be wrong
const db = new aws.rds.Instance('db', { ... });
const app = new aws.ecs.Service('app', {
environment: [{ name: 'DB_HOST', value: db.endpoint }], // Implicit dependency
});
// ✅ Correct: Explicitly declare dependency
const app = new aws.ecs.Service('app', {
environment: [{ name: 'DB_HOST', value: db.endpoint }],
}, { dependsOn: [db] });
Pitfall 3: Secrets Stored in Plaintext
// ❌ Wrong: Password in plaintext
const db = new aws.rds.Instance('db', {
password: 'my-password-123',
});
// ✅ Correct: Use Config Secret
const password = config.requireSecret('dbPassword');
const db = new aws.rds.Instance('db', {
password: password,
});
Pitfall 4: Resource Protection Not Set
// ❌ Wrong: Production database unprotected, could be accidentally deleted
const db = new aws.rds.Instance('prod-db', {
skipFinalSnapshot: true,
});
// ✅ Correct: Enable protection
const db = new aws.rds.Instance('prod-db', {
skipFinalSnapshot: false,
finalSnapshotIdentifier: 'prod-db-final',
}, { protect: true });
Pitfall 5: State File Not Encrypted
# ❌ Wrong: Local file backend, unencrypted
pulumi login --local
# ✅ Correct: Use Pulumi Cloud or encrypted S3 backend
pulumi login
# Or
pulumi login s3://pulumi-state-bucket?region=us-east-1
Error Troubleshooting
| # | Error Message | Cause | Solution |
|---|---|---|---|
| 1 | Cannot read property of Output |
Output not using apply | Use pulumi.interpolate or apply |
| 2 | Resource already exists |
State out of sync with actual | pulumi import existing resource |
| 3 | Conflict: stack is locked |
State lock conflict | pulumi stack unlock |
| 4 | 403 Access Denied |
Invalid AWS credentials | Check AWS_ACCESS_KEY_ID config |
| 5 | Provider credential failed |
Cloud provider auth failed | Verify Provider configuration |
| 6 | Destroy failed: protected resource |
Resource is protected | pulumi state unprotect |
| 7 | Stack not found |
Stack doesn't exist | pulumi stack init to create |
| 8 | Plugin not found |
Provider plugin missing | pulumi plugin install |
| 9 | Drift detected |
State drift from actual | pulumi refresh to sync |
| 10 | Secret exposed in state |
Secret not encrypted | Use requireSecret and encrypted backend |
Advanced Optimization
- Pulumi AI: Auto-generate infrastructure code from natural language descriptions
- Automation API: Embed Pulumi in applications for self-service platforms
- Pulumi Deployments: Auto-trigger deployments on Git push
- CrossGuard: Policy as Code compliance checks
- Pulumi ESC: Centralized environment secrets and config management
Comparison
| Dimension | Pulumi | Terraform | CDK for Terraform | AWS CDK |
|---|---|---|---|---|
| Language | TS/Python/Go/C# | HCL | TS/Python/Java | TS/Python/Java |
| State Management | Pulumi Cloud/S3 | S3/Consul/Cloud | Terraform Cloud | CloudFormation |
| Testing | Unit + Integration | terraform test | CDK testing | CDK testing |
| Multi-cloud | 80+ providers | 3000+ providers | 3000+ providers | AWS only |
| Component Reuse | Native language | Module | CDK Construct | Construct |
| Learning Curve | Medium | Low | Medium-High | Medium |
Summary: Pulumi defines infrastructure with real programming languages, giving IaC the power of loops, conditions, type checking, and unit testing. ComponentResource abstraction + Output async chains + Policy as Code compliance checks form a trinity that evolves infrastructure code from "configuration files" to "engineered code." In 2026, the maturing Pulumi AI and Automation API make infrastructure management more intelligent and automated.
Recommended Online Tools
- JSON Formatter: /en/json/format
- Hash Calculator: /en/encode/hash
- cURL to Code: /en/dev/curl-to-code
Try these browser-local tools — no sign-up required →