Back to Journal
DevOps

Complete Guide to Infrastructure as Code with Typescript

A comprehensive guide to implementing Infrastructure as Code using Typescript, covering architecture, code examples, and production-ready patterns.

Muneer Puthiya Purayil 15 min read

Complete Guide to Infrastructure as Code with TypeScript

TypeScript has emerged as one of the most compelling choices for Infrastructure as Code. The combination of static typing, excellent IDE support, and JavaScript's ubiquity means teams can write infrastructure definitions with the same confidence and tooling they use for application code. Type errors caught at compile time never make it to pulumi up.

This guide covers the full TypeScript IaC landscape — Pulumi, AWS CDK, CDKTF — with production patterns, real code, and the architectural decisions that matter when managing hundreds of cloud resources.

Why TypeScript for Infrastructure as Code

Three factors make TypeScript particularly strong for IaC:

Type safety catches misconfigurations early. When you pass instanceType: "t3.micro" to an EC2 instance, the type system validates it. When you wire a security group to a load balancer, the types ensure you're passing the right resource reference. Entire categories of runtime errors simply disappear.

First-class IDE experience. VS Code with TypeScript provides autocomplete for every AWS service, every GCP resource, every Azure property. You don't need to look up documentation — the types are the documentation. Jump-to-definition on a Pulumi resource takes you straight to the property definitions.

Shared language with application code. If your frontend is React/Next.js, your backend is Node.js, and your infrastructure is TypeScript, you have one language across the stack. This matters for developer experience and hiring.

Pulumi with TypeScript

Pulumi's TypeScript SDK is their most mature and widely used. The developer experience is excellent — autocomplete, type checking, and the full Node.js ecosystem.

Project Setup

bash
pulumi new aws-typescript --name my-infra --stack dev

Generated structure:

1my-infra/
2├── index.ts
3├── Pulumi.yaml
4├── Pulumi.dev.yaml
5├── package.json
6└── tsconfig.json
7 

Production VPC with Type-Safe Networking

typescript
1import * as pulumi from "@pulumi/pulumi";
2import * as aws from "@pulumi/aws";
3 
4const config = new pulumi.Config();
5const env = pulumi.getStack();
6const vpcCidr = config.require("vpcCidr");
7const azCount = config.getNumber("azCount") ?? 3;
8 
9const vpc = new aws.ec2.Vpc(`${env}-vpc`, {
10 cidrBlock: vpcCidr,
11 enableDnsHostnames: true,
12 enableDnsSupport: true,
13 tags: { Name: `${env}-vpc`, Environment: env, ManagedBy: "pulumi" },
14});
15 
16const azs = aws.getAvailabilityZones({ state: "available" });
17 
18const publicSubnets: aws.ec2.Subnet[] = [];
19const privateSubnets: aws.ec2.Subnet[] = [];
20 
21azs.then((available) => {
22 for (let i = 0; i < Math.min(azCount, available.names.length); i++) {
23 const az = available.names[i];
24 
25 publicSubnets.push(
26 new aws.ec2.Subnet(`${env}-public-${az}`, {
27 vpcId: vpc.id,
28 cidrBlock: `10.0.${i * 2}.0/24`,
29 availabilityZone: az,
30 mapPublicIpOnLaunch: true,
31 tags: { Name: `${env}-public-${az}`, Tier: "public" },
32 })
33 );
34 
35 privateSubnets.push(
36 new aws.ec2.Subnet(`${env}-private-${az}`, {
37 vpcId: vpc.id,
38 cidrBlock: `10.0.${i * 2 + 1}.0/24`,
39 availabilityZone: az,
40 tags: { Name: `${env}-private-${az}`, Tier: "private" },
41 })
42 );
43 }
44});
45 
46const igw = new aws.ec2.InternetGateway(`${env}-igw`, {
47 vpcId: vpc.id,
48 tags: { Name: `${env}-igw` },
49});
50 
51const publicRouteTable = new aws.ec2.RouteTable(`${env}-public-rt`, {
52 vpcId: vpc.id,
53 routes: [{ cidrBlock: "0.0.0.0/0", gatewayId: igw.id }],
54});
55 
56publicSubnets.forEach((subnet, i) => {
57 new aws.ec2.RouteTableAssociation(`${env}-public-rta-${i}`, {
58 subnetId: subnet.id,
59 routeTableId: publicRouteTable.id,
60 });
61});
62 
63export const vpcId = vpc.id;
64export const publicSubnetIds = publicSubnets.map((s) => s.id);
65export const privateSubnetIds = privateSubnets.map((s) => s.id);
66 

Component Resources with TypeScript Generics

TypeScript's type system shines when building reusable components:

typescript
1import * as pulumi from "@pulumi/pulumi";
2import * as aws from "@pulumi/aws";
3 
4interface DatabaseClusterArgs {
5 vpcId: pulumi.Input<string>;
6 subnetIds: pulumi.Input<string>[];
7 instanceClass?: string;
8 instanceCount?: number;
9 engineVersion?: string;
10 deletionProtection?: boolean;
11}
12 
13class DatabaseCluster extends pulumi.ComponentResource {
14 public readonly clusterEndpoint: pulumi.Output<string>;
15 public readonly readerEndpoint: pulumi.Output<string>;
16 public readonly securityGroupId: pulumi.Output<string>;
17 
18 constructor(
19 name: string,
20 args: DatabaseClusterArgs,
21 opts?: pulumi.ComponentResourceOptions
22 ) {
23 super("custom:database:PostgresCluster", name, args, opts);
24 
25 const {
26 vpcId,
27 subnetIds,
28 instanceClass = "db.r6g.large",
29 instanceCount = 2,
30 engineVersion = "15.4",
31 deletionProtection = true,
32 } = args;
33 
34 const subnetGroup = new aws.rds.SubnetGroup(
35 `${name}-subnet-group`,
36 { subnetIds, tags: { Name: `${name}-subnet-group` } },
37 { parent: this }
38 );
39 
40 const sg = new aws.ec2.SecurityGroup(
41 `${name}-sg`,
42 {
43 vpcId,
44 ingress: [
45 {
46 protocol: "tcp",
47 fromPort: 5432,
48 toPort: 5432,
49 cidrBlocks: ["10.0.0.0/8"],
50 },
51 ],
52 egress: [
53 {
54 protocol: "-1",
55 fromPort: 0,
56 toPort: 0,
57 cidrBlocks: ["0.0.0.0/0"],
58 },
59 ],
60 },
61 { parent: this }
62 );
63 
64 const cluster = new aws.rds.Cluster(
65 `${name}-cluster`,
66 {
67 engine: "aurora-postgresql",
68 engineVersion,
69 dbSubnetGroupName: subnetGroup.name,
70 vpcSecurityGroupIds: [sg.id],
71 masterUsername: "admin",
72 manageMasterUserPassword: true,
73 storageEncrypted: true,
74 backupRetentionPeriod: 7,
75 preferredBackupWindow: "03:00-04:00",
76 deletionProtection,
77 },
78 { parent: this }
79 );
80 
81 for (let i = 0; i < instanceCount; i++) {
82 new aws.rds.ClusterInstance(
83 `${name}-instance-${i}`,
84 {
85 clusterIdentifier: cluster.id,
86 instanceClass,
87 engine: "aurora-postgresql",
88 engineVersion,
89 },
90 { parent: this }
91 );
92 }
93 
94 this.clusterEndpoint = cluster.endpoint;
95 this.readerEndpoint = cluster.readerEndpoint;
96 this.securityGroupId = sg.id;
97 
98 this.registerOutputs({
99 clusterEndpoint: this.clusterEndpoint,
100 readerEndpoint: this.readerEndpoint,
101 });
102 }
103}
104 
105// Usage — fully typed, autocomplete works everywhere
106const db = new DatabaseCluster("production-db", {
107 vpcId: vpc.id,
108 subnetIds: privateSubnets.map((s) => s.id),
109 instanceClass: "db.r6g.xlarge",
110 instanceCount: 3,
111});
112 
113export const dbEndpoint = db.clusterEndpoint;
114 

The DatabaseClusterArgs interface acts as a contract. Anyone using this component gets autocomplete on every property and compile-time errors for missing required fields.

AWS CDK with TypeScript

AWS CDK was built TypeScript-first, and it shows. The L2 and L3 constructs provide the highest level of abstraction available in any IaC tool.

Serverless API with Full Type Safety

typescript
1import { App, Stack, StackProps, Duration, RemovalPolicy } from "aws-cdk-lib";
2import * as lambda from "aws-cdk-lib/aws-lambda";
3import * as nodejs from "aws-cdk-lib/aws-lambda-nodejs";
4import * as apigw from "aws-cdk-lib/aws-apigateway";
5import * as dynamodb from "aws-cdk-lib/aws-dynamodb";
6import * as logs from "aws-cdk-lib/aws-logs";
7import { Construct } from "constructs";
8 
9interface ApiStackProps extends StackProps {
10 stage: "dev" | "staging" | "production";
11 domainName?: string;
12}
13 
14class ApiStack extends Stack {
15 public readonly apiUrl: string;
16 
17 constructor(scope: Construct, id: string, props: ApiStackProps) {
18 super(scope, id, props);
19 
20 const table = new dynamodb.Table(this, "OrdersTable", {
21 partitionKey: { name: "pk", type: dynamodb.AttributeType.STRING },
22 sortKey: { name: "sk", type: dynamodb.AttributeType.STRING },
23 billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
24 pointInTimeRecovery: true,
25 removalPolicy:
26 props.stage === "production"
27 ? RemovalPolicy.RETAIN
28 : RemovalPolicy.DESTROY,
29 timeToLiveAttribute: "ttl",
30 });
31 
32 table.addGlobalSecondaryIndex({
33 indexName: "gsi1",
34 partitionKey: { name: "gsi1pk", type: dynamodb.AttributeType.STRING },
35 sortKey: { name: "gsi1sk", type: dynamodb.AttributeType.STRING },
36 });
37 
38 const handler = new nodejs.NodejsFunction(this, "OrderHandler", {
39 entry: "lambda/orders/handler.ts",
40 handler: "handler",
41 runtime: lambda.Runtime.NODEJS_20_X,
42 memorySize: 256,
43 timeout: Duration.seconds(30),
44 environment: {
45 TABLE_NAME: table.tableName,
46 STAGE: props.stage,
47 NODE_OPTIONS: "--enable-source-maps",
48 },
49 bundling: {
50 minify: true,
51 sourceMap: true,
52 treeshaking: true,
53 },
54 logRetention: logs.RetentionDays.TWO_WEEKS,
55 tracing: lambda.Tracing.ACTIVE,
56 });
57 
58 table.grantReadWriteData(handler);
59 
60 const api = new apigw.RestApi(this, "OrdersApi", {
61 restApiName: `${props.stage}-orders-api`,
62 deployOptions: {
63 stageName: "v1",
64 throttlingRateLimit: props.stage === "production" ? 1000 : 100,
65 throttlingBurstLimit: props.stage === "production" ? 500 : 50,
66 loggingLevel: apigw.MethodLoggingLevel.INFO,
67 metricsEnabled: true,
68 tracingEnabled: true,
69 },
70 });
71 
72 const orders = api.root.addResource("orders");
73 const integration = new apigw.LambdaIntegration(handler);
74 orders.addMethod("GET", integration);
75 orders.addMethod("POST", integration);
76 orders.addResource("{orderId}").addMethod("GET", integration);
77 
78 this.apiUrl = api.url;
79 }
80}
81 
82const app = new App();
83 
84new ApiStack(app, "DevApi", { stage: "dev", env: { region: "us-east-1" } });
85new ApiStack(app, "ProdApi", {
86 stage: "production",
87 env: { region: "us-east-1", account: "123456789012" },
88 domainName: "api.example.com",
89});
90 
91app.synth();
92 

The NodejsFunction construct automatically bundles TypeScript Lambda code with esbuild — no separate build step needed.

Custom Constructs for Organizational Standards

typescript
1import { Construct } from "constructs";
2import * as ec2 from "aws-cdk-lib/aws-ec2";
3import * as ecs from "aws-cdk-lib/aws-ecs";
4import * as ecs_patterns from "aws-cdk-lib/aws-ecs-patterns";
5import * as cdk from "aws-cdk-lib";
6 
7interface StandardServiceProps {
8 vpc: ec2.IVpc;
9 image: ecs.ContainerImage;
10 cpu?: number;
11 memoryMiB?: number;
12 desiredCount?: number;
13 healthCheckPath?: string;
14 environment?: Record<string, string>;
15 scaling?: {
16 minCapacity: number;
17 maxCapacity: number;
18 targetCpuPercent: number;
19 };
20}
21 
22class StandardFargateService extends Construct {
23 public readonly service: ecs_patterns.ApplicationLoadBalancedFargateService;
24 public readonly dnsName: string;
25 
26 constructor(scope: Construct, id: string, props: StandardServiceProps) {
27 super(scope, id);
28 
29 const {
30 vpc,
31 image,
32 cpu = 256,
33 memoryMiB = 512,
34 desiredCount = 2,
35 healthCheckPath = "/health",
36 environment = {},
37 scaling = { minCapacity: 2, maxCapacity: 10, targetCpuPercent: 70 },
38 } = props;
39 
40 const cluster = new ecs.Cluster(this, "Cluster", {
41 vpc,
42 containerInsights: true,
43 });
44 
45 this.service = new ecs_patterns.ApplicationLoadBalancedFargateService(
46 this,
47 "Service",
48 {
49 cluster,
50 cpu,
51 memoryLimitMiB: memoryMiB,
52 desiredCount,
53 taskImageOptions: {
54 image,
55 environment: {
56 ...environment,
57 NODE_ENV: "production",
58 },
59 containerPort: 8080,
60 },
61 publicLoadBalancer: true,
62 circuitBreaker: { rollback: true },
63 }
64 );
65 
66 this.service.targetGroup.configureHealthCheck({
67 path: healthCheckPath,
68 healthyThresholdCount: 2,
69 unhealthyThresholdCount: 3,
70 interval: cdk.Duration.seconds(30),
71 });
72 
73 const scalableTarget = this.service.service.autoScaleTaskCount({
74 minCapacity: scaling.minCapacity,
75 maxCapacity: scaling.maxCapacity,
76 });
77 
78 scalableTarget.scaleOnCpuUtilization("CpuScaling", {
79 targetUtilizationPercent: scaling.targetCpuPercent,
80 scaleInCooldown: cdk.Duration.seconds(60),
81 scaleOutCooldown: cdk.Duration.seconds(30),
82 });
83 
84 this.dnsName = this.service.loadBalancer.loadBalancerDnsName;
85 }
86}
87 

CDK for Terraform (CDKTF) with TypeScript

CDKTF bridges Terraform's provider ecosystem with TypeScript's type system:

typescript
1import { Construct } from "constructs";
2import { App, TerraformStack, TerraformOutput, S3Backend } from "cdktf";
3import { AwsProvider } from "@cdktf/provider-aws/lib/provider";
4import { Instance } from "@cdktf/provider-aws/lib/instance";
5import { SecurityGroup } from "@cdktf/provider-aws/lib/security-group";
6import { Vpc } from "@cdktf/provider-aws/lib/vpc";
7import { Subnet } from "@cdktf/provider-aws/lib/subnet";
8 
9interface InfraConfig {
10 env: string;
11 region: string;
12 vpcCidr: string;
13 instanceType: string;
14 amiId: string;
15}
16 
17class WebServerStack extends TerraformStack {
18 constructor(scope: Construct, id: string, config: InfraConfig) {
19 super(scope, id);
20 
21 new AwsProvider(this, "aws", { region: config.region });
22 
23 new S3Backend(this, {
24 bucket: "my-terraform-state",
25 key: `webserver/${config.env}/terraform.tfstate`,
26 region: config.region,
27 dynamodbTable: "terraform-locks",
28 });
29 
30 const vpc = new Vpc(this, "vpc", {
31 cidrBlock: config.vpcCidr,
32 enableDnsHostnames: true,
33 tags: { Name: `${config.env}-vpc`, Environment: config.env },
34 });
35 
36 const subnet = new Subnet(this, "subnet", {
37 vpcId: vpc.id,
38 cidrBlock: "10.0.1.0/24",
39 mapPublicIpOnLaunch: true,
40 tags: { Name: `${config.env}-public` },
41 });
42 
43 const sg = new SecurityGroup(this, "sg", {
44 vpcId: vpc.id,
45 ingress: [
46 { protocol: "tcp", fromPort: 443, toPort: 443, cidrBlocks: ["0.0.0.0/0"] },
47 { protocol: "tcp", fromPort: 80, toPort: 80, cidrBlocks: ["0.0.0.0/0"] },
48 ],
49 egress: [
50 { protocol: "-1", fromPort: 0, toPort: 0, cidrBlocks: ["0.0.0.0/0"] },
51 ],
52 });
53 
54 const server = new Instance(this, "web", {
55 ami: config.amiId,
56 instanceType: config.instanceType,
57 subnetId: subnet.id,
58 vpcSecurityGroupIds: [sg.id],
59 tags: { Name: `${config.env}-web`, Environment: config.env },
60 });
61 
62 new TerraformOutput(this, "publicIp", { value: server.publicIp });
63 new TerraformOutput(this, "instanceId", { value: server.id });
64 }
65}
66 
67const app = new App();
68 
69new WebServerStack(app, "staging", {
70 env: "staging",
71 region: "us-east-1",
72 vpcCidr: "10.0.0.0/16",
73 instanceType: "t3.small",
74 amiId: "ami-0c55b159cbfafe1f0",
75});
76 
77new WebServerStack(app, "production", {
78 env: "production",
79 region: "us-east-1",
80 vpcCidr: "10.1.0.0/16",
81 instanceType: "t3.large",
82 amiId: "ami-0c55b159cbfafe1f0",
83});
84 
85app.synth();
86 

Testing Infrastructure Code

TypeScript's testing ecosystem works seamlessly with IaC:

Unit Testing with Pulumi

typescript
1import * as pulumi from "@pulumi/pulumi";
2import { describe, it, expect, beforeAll } from "vitest";
3 
4// Mock Pulumi runtime
5pulumi.runtime.setMocks({
6 newResource: (args) => ({
7 id: `${args.name}-id`,
8 state: args.inputs,
9 }),
10 call: () => ({}),
11});
12 
13describe("DatabaseCluster", () => {
14 let cluster: any;
15 
16 beforeAll(async () => {
17 const { DatabaseCluster } = await import("../src/database");
18 cluster = new DatabaseCluster("test-db", {
19 vpcId: "vpc-123",
20 subnetIds: ["subnet-1", "subnet-2"],
21 instanceClass: "db.r6g.large",
22 instanceCount: 2,
23 });
24 });
25 
26 it("enables storage encryption", async () => {
27 const encrypted = await new Promise<boolean>((resolve) =>
28 cluster.cluster.storageEncrypted.apply(resolve)
29 );
30 expect(encrypted).toBe(true);
31 });
32 
33 it("enables deletion protection", async () => {
34 const protection = await new Promise<boolean>((resolve) =>
35 cluster.cluster.deletionProtection.apply(resolve)
36 );
37 expect(protection).toBe(true);
38 });
39 
40 it("creates correct number of instances", () => {
41 expect(cluster.instances).toHaveLength(2);
42 });
43});
44 

CDK Assertion Testing

typescript
1import { Template, Match } from "aws-cdk-lib/assertions";
2import { App } from "aws-cdk-lib";
3import { ApiStack } from "../lib/api-stack";
4 
5describe("ApiStack", () => {
6 const app = new App();
7 const stack = new ApiStack(app, "TestStack", {
8 stage: "production",
9 env: { region: "us-east-1" },
10 });
11 const template = Template.fromStack(stack);
12 
13 it("creates DynamoDB table with PAY_PER_REQUEST billing", () => {
14 template.hasResourceProperties("AWS::DynamoDB::Table", {
15 BillingMode: "PAY_PER_REQUEST",
16 PointInTimeRecoverySpecification: { PointInTimeRecoveryEnabled: true },
17 });
18 });
19 
20 it("retains production DynamoDB tables", () => {
21 template.hasResource("AWS::DynamoDB::Table", {
22 DeletionPolicy: "Retain",
23 UpdateReplacePolicy: "Retain",
24 });
25 });
26 
27 it("sets production API throttling", () => {
28 template.hasResourceProperties("AWS::ApiGateway::Stage", {
29 MethodSettings: Match.arrayWith([
30 Match.objectLike({
31 ThrottlingRateLimit: 1000,
32 ThrottlingBurstLimit: 500,
33 }),
34 ]),
35 });
36 });
37 
38 it("grants Lambda read/write access to DynamoDB", () => {
39 template.hasResourceProperties("AWS::IAM::Policy", {
40 PolicyDocument: {
41 Statement: Match.arrayWith([
42 Match.objectLike({
43 Action: Match.arrayWith([
44 "dynamodb:BatchGetItem",
45 "dynamodb:GetItem",
46 "dynamodb:PutItem",
47 ]),
48 }),
49 ]),
50 },
51 });
52 });
53});
54 

Need a second opinion on your DevOps pipelines architecture?

I run free 30-minute strategy calls for engineering teams tackling this exact problem.

Book a Free Call

Advanced Patterns

Dynamic Provider Configuration

typescript
1import * as pulumi from "@pulumi/pulumi";
2import * as aws from "@pulumi/aws";
3 
4type Region = "us-east-1" | "us-west-2" | "eu-west-1";
5 
6interface MultiRegionConfig {
7 primary: Region;
8 replicas: Region[];
9}
10 
11function createRegionalProvider(region: Region): aws.Provider {
12 return new aws.Provider(`aws-${region}`, { region });
13}
14 
15function deployToRegions(config: MultiRegionConfig) {
16 const providers = new Map<Region, aws.Provider>();
17 
18 const allRegions = [config.primary, ...config.replicas];
19 for (const region of allRegions) {
20 providers.set(region, createRegionalProvider(region));
21 }
22 
23 const buckets = allRegions.map((region) => {
24 const provider = providers.get(region)!;
25 return new aws.s3.BucketV2(
26 `data-${region}`,
27 { bucket: `my-app-data-${region}` },
28 { provider }
29 );
30 });
31 
32 return { providers, buckets };
33}
34 

Type-Safe Stack Configuration

typescript
1import * as pulumi from "@pulumi/pulumi";
2import { z } from "zod";
3 
4const StackConfigSchema = z.object({
5 vpcCidr: z.string().regex(/^\d+\.\d+\.\d+\.\d+\/\d+$/),
6 instanceType: z.enum(["t3.micro", "t3.small", "t3.medium", "t3.large"]),
7 minInstances: z.number().min(1).max(20),
8 maxInstances: z.number().min(1).max(100),
9 enableMonitoring: z.boolean(),
10 alertEmail: z.string().email(),
11});
12 
13type StackConfig = z.infer<typeof StackConfigSchema>;
14 
15function loadConfig(): StackConfig {
16 const config = new pulumi.Config();
17 
18 const raw = {
19 vpcCidr: config.require("vpcCidr"),
20 instanceType: config.require("instanceType"),
21 minInstances: config.requireNumber("minInstances"),
22 maxInstances: config.requireNumber("maxInstances"),
23 enableMonitoring: config.requireBoolean("enableMonitoring"),
24 alertEmail: config.require("alertEmail"),
25 };
26 
27 const result = StackConfigSchema.safeParse(raw);
28 if (!result.success) {
29 throw new Error(
30 `Invalid stack configuration:\n${result.error.issues
31 .map((i) => ` ${i.path.join(".")}: ${i.message}`)
32 .join("\n")}`
33 );
34 }
35 
36 return result.data;
37}
38 

This pattern catches configuration errors before any cloud API calls are made. Invalid CIDR blocks, wrong instance types, or missing emails fail immediately with clear messages.

Secrets Management

typescript
1import * as pulumi from "@pulumi/pulumi";
2import * as aws from "@pulumi/aws";
3 
4const config = new pulumi.Config();
5 
6// Encrypted in Pulumi state
7const dbPassword = config.requireSecret("dbPassword");
8 
9const secret = new aws.secretsmanager.Secret("db-credentials", {
10 name: "production/db/credentials",
11});
12 
13new aws.secretsmanager.SecretVersion("db-credentials-v1", {
14 secretId: secret.id,
15 secretString: dbPassword.apply((pw) =>
16 JSON.stringify({ username: "admin", password: pw })
17 ),
18});
19 
20// Reference in ECS without exposing the value
21const taskDef = new aws.ecs.TaskDefinition("api-task", {
22 family: "api",
23 requiresCompatibilities: ["FARGATE"],
24 networkMode: "awsvpc",
25 cpu: "512",
26 memory: "1024",
27 executionRoleArn: executionRole.arn,
28 containerDefinitions: pulumi
29 .all([secret.arn])
30 .apply(([secretArn]) =>
31 JSON.stringify([
32 {
33 name: "api",
34 image: "api:latest",
35 secrets: [{ name: "DB_CREDENTIALS", valueFrom: secretArn }],
36 },
37 ])
38 ),
39});
40 

CI/CD Integration

yaml
1name: Infrastructure
2on:
3 push:
4 branches: [main]
5 paths: ['infra/**']
6 pull_request:
7 paths: ['infra/**']
8 
9jobs:
10 preview:
11 if: github.event_name == 'pull_request'
12 runs-on: ubuntu-latest
13 steps:
14 - uses: actions/checkout@v4
15 - uses: actions/setup-node@v4
16 with:
17 node-version: '20'
18 cache: 'npm'
19 cache-dependency-path: infra/package-lock.json
20 
21 - run: npm ci
22 working-directory: infra
23 
24 - run: npm test
25 working-directory: infra
26 
27 - uses: pulumi/actions@v5
28 with:
29 command: preview
30 work-dir: infra
31 stack-name: staging
32 comment-on-pr: true
33 env:
34 PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }}
35 
36 deploy:
37 if: github.ref == 'refs/heads/main'
38 runs-on: ubuntu-latest
39 environment: production
40 steps:
41 - uses: actions/checkout@v4
42 - uses: actions/setup-node@v4
43 with:
44 node-version: '20'
45 cache: 'npm'
46 
47 - run: npm ci
48 working-directory: infra
49 
50 - run: npm test
51 working-directory: infra
52 
53 - uses: pulumi/actions@v5
54 with:
55 command: up
56 work-dir: infra
57 stack-name: production
58 env:
59 PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }}
60 

Choosing Between Pulumi, CDK, and CDKTF

FactorPulumiAWS CDKCDKTF
Cloud supportMulti-cloudAWS onlyMulti-cloud
Abstraction levelLow-to-mid (L1)High (L2/L3)Low-to-mid
State backendPulumi Cloud / S3CloudFormationTerraform state
TypeScript supportFirst-classFirst-classFirst-class
Provider ecosystemNative + TF bridgeAWS onlyAll Terraform providers
Execution speedDirect API callsCloudFormation (slower)Terraform engine
Community sizeGrowingLargest (AWS)Growing

Choose Pulumi if you want multi-cloud support with the most direct TypeScript experience and fastest execution.

Choose AWS CDK if you're AWS-only and want the highest-level abstractions — grant* methods, L2/L3 constructs, and automatic IAM policies save significant time.

Choose CDKTF if you have existing Terraform state and providers but want TypeScript instead of HCL.

Conclusion

TypeScript provides arguably the best developer experience for Infrastructure as Code. The combination of static typing, IDE autocomplete, and the ability to use interface definitions as contracts for infrastructure components eliminates entire categories of errors. When your infrastructure definition won't compile, it won't break in production.

The ecosystem is mature across all three major tools. Pulumi, AWS CDK, and CDKTF all treat TypeScript as a first-class citizen. The testing story is strong — CDK assertions, Pulumi mocks, and standard testing frameworks like Vitest give you confidence that infrastructure changes behave as expected before they touch real resources.

For teams already building in TypeScript, adopting TypeScript for IaC is a natural extension. One language, one type system, one set of linting rules across application code and infrastructure. The reduction in context-switching and the ability to share types between application and infrastructure code — like passing an API endpoint URL from a CDK stack directly into a Lambda environment — makes the full-stack TypeScript approach compelling.

FAQ

Need expert help?

Building with CI/CD pipelines?

I help teams ship production-grade systems. From architecture review to hands-on builds.

Muneer Puthiya Purayil

SaaS Architect & AI Systems Engineer. 10+ years shipping production infrastructure across fintech, automotive, e-commerce, and healthcare.

Engage

Start a
Conversation.

For teams building at scale: SaaS platforms, agentic AI systems, and enterprise mobile infrastructure. Scope and fit are evaluated before any engagement begins.

Limited availability · Q3 / Q4 2026