- Published on
Pulumi With TypeScript — Infrastructure as Real Code, Not YAML
- Authors

- Name
- Sanjeev Sharma
- @webcoderspeed1
Introduction
Terraform forces you into HCL, a domain-specific language optimized for declarative configuration but hostile to logic. Pulumi inverts that: you write real TypeScript, Python, or Go, and infrastructure emerges from type-safe, testable code. Loops, functions, and conditions become natural instead of arcane for_each workarounds.
- Pulumi vs Terraform (Real Programming Language vs DSL)
- Pulumi Program Structure
- AWS Provider (ECS, RDS, VPC, S3, Lambda)
- Dynamic Resource Creation With Loops/Conditions
- Pulumi Component Resources for Reusable Patterns
- Stack References for Cross-Stack Dependencies
- Pulumi ESC for Secrets Management
- Pulumi AI for Generating Infrastructure Code
- State Management (Pulumi Cloud vs S3)
- Testing Infrastructure With pulumi.runtime
- Checklist
- Conclusion
Pulumi vs Terraform (Real Programming Language vs DSL)
Terraform approach:
# Ugly nested loops in HCL
variable "instance_count" {
default = 3
}
resource "aws_instance" "app" {
count = var.instance_count
instance_type = "t3.micro"
availability_zone = data.aws_availability_zones.available.names[count.index % length(data.aws_availability_zones.available.names)]
}
# HCL can't express: "create one instance per AZ, skip if region is us-west-2"
# You need a separate JSON map or locals block
Pulumi approach:
import * as aws from '@pulumi/aws';
const azs = aws.getAvailabilityZones();
const instances = azs.names.map(
az => new aws.ec2.Instance(`app-${az}`, {
ami: data.aws_ami.ubuntu.id,
instanceType: 't3.micro',
availabilityZone: az,
})
);
// Easy: create instances only in us-east-1
const region = aws.config.region;
if (region === 'us-east-1') {
console.log(`Created ${instances.length} instances`);
}
Pulumi allows real conditional logic, loops, and helper functions—the entire TypeScript standard library is at your disposal.
Pulumi Program Structure
A Pulumi project contains:
my-infrastructure/
├── Pulumi.yaml # Project metadata
├── Pulumi.prod.yaml # Production stack config
├── tsconfig.json # TypeScript configuration
├── index.ts # Main infrastructure file
└── components/
├── vpc.ts # Reusable VPC component
└── ecs-cluster.ts # Reusable ECS cluster
Pulumi.yaml:
name: my-infrastructure
runtime: nodejs
description: Production AWS infrastructure
Pulumi.prod.yaml:
config:
aws:region: us-east-1
database_size: db.r6g.xlarge
instance_count: 3
index.ts:
import * as pulumi from '@pulumi/pulumi';
import * as aws from '@pulumi/aws';
const config = new pulumi.Config();
const dbSize = config.require('database_size');
const instanceCount = config.getNumber('instance_count') || 1;
// Define infrastructure here
export const outputValue = 'something';
Outputs are exported and available to other stacks.
AWS Provider (ECS, RDS, VPC, S3, Lambda)
Define a complete AWS stack:
import * as aws from '@pulumi/aws';
import * as pulumi from '@pulumi/pulumi';
const config = new pulumi.Config();
// VPC with public/private subnets
const vpc = new aws.ec2.Vpc('main-vpc', {
cidrBlock: '10.0.0.0/16',
enableDnsHostnames: true,
enableDnsSupport: true,
});
// Security group
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'] },
],
});
// RDS Postgres cluster
const dbCluster = new aws.rds.Cluster('main-db', {
clusterIdentifier: 'main-db-prod',
engine: 'aurora-postgresql',
engineVersion: '15.2',
databaseName: 'appdb',
masterUsername: 'postgres',
masterUserPassword: config.requireSecret('db_password'),
vpcSecurityGroupIds: [sg.id],
storageEncrypted: true,
backupRetentionPeriod: 30,
});
// S3 bucket for uploads
const bucket = new aws.s3.Bucket('app-uploads', {
acl: 'private',
serverSideEncryptionConfiguration: {
rule: {
applyServerSideEncryptionByDefault: {
sseAlgorithm: 'AES256',
},
},
},
});
// ECS cluster
const ecsCluster = new aws.ecs.Cluster('app-cluster', {
name: 'app-prod',
});
// Lambda function
const lambda = new aws.lambda.Function('process-upload', {
role: lambdaRole.arn,
handler: 'index.handler',
runtime: 'nodejs20.x',
code: new pulumi.asset.AssetArchive({
'.': new pulumi.asset.FileArchive('./lambda'),
}),
environment: {
variables: {
BUCKET_NAME: bucket.id,
DB_HOST: dbCluster.endpoint,
},
},
});
export const dbEndpoint = dbCluster.endpoint;
export const bucketName = bucket.id;
export const lambdaArn = lambda.arn;
Type safety catches errors at deploy time, not in production.
Dynamic Resource Creation With Loops/Conditions
Create resources programmatically:
import * as aws from '@pulumi/aws';
const config = new pulumi.Config();
const environment = pulumi.getStack();
const enableHighAvailability = config.getBoolean('ha_mode') || false;
// Create multiple database replicas in different regions
const regions = ['us-east-1', 'us-west-2', 'eu-west-1'];
const replicas = regions.map(region => {
if (enableHighAvailability || region === 'us-east-1') {
return new aws.rds.ClusterInstance(`db-replica-${region}`, {
clusterIdentifier: mainDbCluster.id,
instanceClass: 'db.r6g.large',
engine: mainDbCluster.engine,
publiclyAccessible: false,
autoMinorVersionUpgrade: false,
});
}
return null;
}).filter(r => r !== null);
console.log(`Created ${replicas.length} database replicas`);
// Conditionally provision load balancer
if (environment === 'production') {
const alb = new aws.lb.LoadBalancer('main-alb', {
internal: false,
loadBalancerType: 'application',
securityGroups: [sg.id],
});
}
Loops and conditionals eliminate DSL complexity.
Pulumi Component Resources for Reusable Patterns
Encapsulate infrastructure patterns as reusable components:
// components/vpc.ts
import * as aws from '@pulumi/aws';
import * as pulumi from '@pulumi/pulumi';
interface VpcArgs {
cidrBlock: string;
enableFlowLogs: boolean;
}
export class VpcStack extends pulumi.ComponentResource {
public vpc: aws.ec2.Vpc;
public publicSubnets: aws.ec2.Subnet[];
public privateSubnets: aws.ec2.Subnet[];
public securityGroup: aws.ec2.SecurityGroup;
constructor(name: string, args: VpcArgs, opts?: pulumi.ComponentResourceOptions) {
super('custom:network:Vpc', name, {}, opts);
this.vpc = new aws.ec2.Vpc(`${name}-vpc`, {
cidrBlock: args.cidrBlock,
enableDnsHostnames: true,
enableDnsSupport: true,
}, { parent: this });
// Create public/private subnets
this.publicSubnets = ['a', 'b'].map(suffix =>
new aws.ec2.Subnet(`${name}-public-${suffix}`, {
vpcId: this.vpc.id,
cidrBlock: `${args.cidrBlock.split('.')[0]}.${args.cidrBlock.split('.')[1]}.${suffix === 'a' ? 0 : 1}.0/24`,
availabilityZone: `us-east-1${suffix}`,
mapPublicIpOnLaunch: true,
}, { parent: this })
);
this.privateSubnets = ['a', 'b'].map(suffix =>
new aws.ec2.Subnet(`${name}-private-${suffix}`, {
vpcId: this.vpc.id,
cidrBlock: `${args.cidrBlock.split('.')[0]}.${args.cidrBlock.split('.')[1]}.${parseInt(suffix.charCodeAt(0)) + 2}.0/24`,
availabilityZone: `us-east-1${suffix}`,
}, { parent: this })
);
this.registerOutputs({
vpc: this.vpc,
publicSubnets: this.publicSubnets,
privateSubnets: this.privateSubnets,
});
}
}
Use in main file:
const vpc = new VpcStack('prod', {
cidrBlock: '10.0.0.0/16',
enableFlowLogs: true,
});
Components promote DRY infrastructure code.
Stack References for Cross-Stack Dependencies
Reference outputs from other stacks:
// networking/index.ts
export const vpcId = vpc.id;
export const securityGroupId = sg.id;
// application/index.ts
import * as pulumi from '@pulumi/pulumi';
const stackRef = new pulumi.StackReference(`myorg/project/prod-networking`);
const vpcId = stackRef.getOutput('vpcId');
const sgId = stackRef.getOutput('securityGroupId');
// Use in application stack
const instance = new aws.ec2.Instance('app', {
ami: 'ami-0c55b159cbfafe1f0',
instanceType: 't3.micro',
vpcSecurityGroupIds: [sgId],
subnetId: subnetId,
});
Separate stacks reduce blast radius and enable parallel deployment.
Pulumi ESC for Secrets Management
Pulumi ESC (Environments, Secrets, Configuration) stores sensitive values:
$ pulumi env init
$ pulumi secrets set AWS_SECRET_ACCESS_KEY=xxx
Access in code:
const config = new pulumi.Config();
const dbPassword = config.requireSecret('db_password');
const dbCluster = new aws.rds.Cluster('main-db', {
masterUserPassword: dbPassword,
});
Secrets are encrypted at rest and in transit.
Pulumi AI for Generating Infrastructure Code
Pulumi AI (preview) generates infrastructure from natural language:
$ pulumi ai "Create a VPC with public and private subnets, RDS Postgres, and ECS cluster"
AI generates TypeScript scaffolding, reducing boilerplate for common patterns.
State Management (Pulumi Cloud vs S3)
Store state in Pulumi Cloud (free tier, 1 user) or self-hosted S3:
# Use Pulumi Cloud (default)
$ pulumi login
# Self-hosted S3 backend
$ pulumi login s3://my-pulumi-state
State contains resource IDs and metadata for drift detection.
Testing Infrastructure With pulumi.runtime
Unit test infrastructure before deployment:
import * as pulumi from '@pulumi/pulumi';
import * as assert from 'assert';
describe('Infrastructure', () => {
it('VPC has correct CIDR block', async () => {
const outputs = await pulumi.automation.selectStack({
stackName: 'dev',
projectName: 'my-infrastructure',
program: pulumi.automation.inline({
// Program definition
}),
});
const vpcId = await outputs.getOutput('vpcId');
assert.strictEqual(typeof vpcId, 'string');
});
});
Testing catches configuration errors before production.
Checklist
- Install Pulumi CLI and authenticate to Pulumi Cloud (or self-hosted backend)
- Initialize Pulumi TypeScript project with
pulumi new aws-typescript - Define AWS provider region and authentication
- Create reusable component resources for VPC, databases, and clusters
- Implement cross-stack references for multi-stack architectures
- Set up Pulumi ESC for secrets management
- Test infrastructure with unit tests
- Configure CI/CD to run
pulumi previewandpulumi upon approval - Enable automatic stack tags for cost allocation
- Document stack dependencies and deployment order
Conclusion
Pulumi bridges the gap between imperative code and declarative infrastructure. TypeScript's type safety, standard library, and familiar syntax make infrastructure as maintainable as application code. For teams tired of HCL boilerplate, Pulumi offers a pathway to infrastructure automation that scales with codebase complexity.