The Evolution of Infrastructure as Code
Infrastructure as Code has come a long way since the days of manual server provisioning and configuration drift. The journey from shell scripts to declarative configuration languages represented a massive leap forward. But in 2026, we're seeing another paradigm shift: infrastructure defined using general-purpose programming languages.
The HCL Era
Terraform's HCL (HashiCorp Configuration Language) revolutionized infrastructure management with its declarative approach. It was simple, readable, and purpose-built for infrastructure. But as systems grew more complex, teams started hitting limitations:
- Limited abstraction capabilities
- No native testing frameworks
- Difficult to share and reuse code
- Steep learning curve for developers
The Programming Language Revolution
The new generation of IaC tools embraces general-purpose programming languages. This brings:
- Familiar Syntax: Use TypeScript, Python, Go, or C# instead of learning HCL
- Abstraction: Build reusable components with classes, functions, and modules
- Testing: Unit tests, integration tests, and property-based testing
- IDE Support: Autocomplete, refactoring, and debugging
- Ecosystem: Access to package managers and existing libraries
The shift to programming languages isn't about making infrastructure more complexβit's about making it more maintainable. Teams can apply the same software engineering practices they use for application code to their infrastructure.
Tool Comparison
Let's compare the three leading modern IaC tools across key dimensions:
| Feature | Pulumi | Terraform CDK | AWS CDK |
|---|---|---|---|
| Primary Languages | TypeScript, Python, Go, C#, Java | TypeScript, Python, Java, C#, Go | TypeScript, Python, Java, C#, Go |
| State Management | Pulumi Cloud (default), self-hosted | Terraform state (local, S3, etc.) | CloudFormation (managed) |
| Cloud Providers | 70+ providers | All Terraform providers | AWS (native), others via CFN |
| Execution Model | Direct API calls | Generates Terraform HCL | Generates CloudFormation |
| Testing | Unit, integration, policy-as-code | Limited (via Terraform) | Unit tests, integration tests |
| Pricing | Free (self-hosted), $$$ (Pulumi Cloud) | Free (open source) | Free (AWS service) |
| Learning Curve | Medium | High (requires Terraform knowledge) | Medium (AWS-focused) |
| Best For | Multi-cloud, modern teams | Existing Terraform users | AWS-centric organizations |
Deep Dive: Pulumi
Pulumi is the most mature of the "true" programming language IaC tools. It doesn't generate intermediate configurationβit makes direct API calls to cloud providers using your code.
Architecture
# Pulumi Architecture
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Your Code (TS/Python/Go/C#/Java) β
β βββββββββββββββ βββββββββββββββ βββββββββββββββ β
β β Resources β β Logic β β Tests β β
β βββββββββββββββ βββββββββββββββ βββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Pulumi CLI + Language Runtime β
β (Executes your program, builds resource graph) β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Pulumi Cloud / Self-Hosted State β
β (Stores state, manages concurrency) β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Cloud Provider APIs (AWS, Azure, GCP...) β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Getting Started with Pulumi
# Install Pulumi
curl -fsSL https://get.pulumi.com | sh
# Create a new project
pulumi new aws-typescript
# Preview changes
pulumi preview
# Deploy
pulumi up
# Destroy
pulumi destroy
Pulumi Example: EKS Cluster
import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";
import * as awsx from "@pulumi/awsx";
import * as eks from "@pulumi/eks";
// Configuration
const config = new pulumi.Config();
const minClusterSize = config.getNumber("minClusterSize") || 3;
const maxClusterSize = config.getNumber("maxClusterSize") || 6;
const desiredClusterSize = config.getNumber("desiredClusterSize") || 3;
const eksNodeInstanceType = config.get("eksNodeInstanceType") || "t3.medium";
const vpcNetworkCidr = config.get("vpcNetworkCidr") || "10.0.0.0/16";
// Create VPC
const vpc = new awsx.ec2.Vpc("eks-vpc", {
enableDnsHostnames: true,
cidrBlock: vpcNetworkCidr,
});
// Create EKS Cluster
const cluster = new eks.Cluster("eks-cluster", {
vpcId: vpc.vpcId,
subnetIds: vpc.publicSubnetIds,
instanceType: eksNodeInstanceType,
desiredCapacity: desiredClusterSize,
minSize: minClusterSize,
maxSize: maxClusterSize,
nodeAmiId: "auto",
version: "1.29",
// Enable OIDC provider for IRSA
createOidcProvider: true,
// Cluster access
providerCredentialOpts: {
profileName: "default",
},
});
// Export values
export const kubeconfig = cluster.kubeconfig;
export const vpcId = vpc.vpcId;
export const clusterName = cluster.eksCluster.name;
export const clusterEndpoint = cluster.eksCluster.endpoint;
// Create a Kubernetes provider
const k8sProvider = new k8s.Provider("k8s", {
kubeconfig: cluster.kubeconfig,
});
// Deploy NGINX using the Kubernetes provider
const nginxDeployment = new k8s.apps.v1.Deployment("nginx", {
metadata: { labels: { app: "nginx" } },
spec: {
replicas: 2,
selector: { matchLabels: { app: "nginx" } },
template: {
metadata: { labels: { app: "nginx" } },
spec: {
containers: [{
name: "nginx",
image: "nginx:alpine",
ports: [{ containerPort: 80 }],
}],
},
},
},
}, { provider: k8sProvider });
// Create a load balancer service
const nginxService = new k8s.core.v1.Service("nginx", {
metadata: { labels: { app: "nginx" } },
spec: {
type: "LoadBalancer",
ports: [{ port: 80, targetPort: 80 }],
selector: { app: "nginx" },
},
}, { provider: k8sProvider });
export const nginxUrl = nginxService.status.loadBalancer.ingress[0].hostname;
Pulumi's Unique Features
- Stack References: Share outputs between projects
- Component Resources: Create reusable abstractions
- Dynamic Providers: Custom resource logic in your language
- Policy as Code: CrossGuard for compliance
- Automation API: Embed Pulumi in applications
- True programming language support with full IDE integration
- Excellent testing support (unit, integration, property-based)
- Strong abstraction capabilities with Component Resources
- Direct API execution (no intermediate generation)
- Great multi-cloud support
Deep Dive: Terraform CDK
Terraform CDK (Cloud Development Kit) allows you to define infrastructure using familiar programming languages while still leveraging Terraform's robust provider ecosystem and state management.
How CDKTF Works
# CDKTF Workflow
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Your Code (TS/Python/C#/Java/Go) β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β CDKTF CLI synthesizes code β
β β Generates Terraform JSON β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Terraform Core β
β (Plan, Apply, State Management) β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Terraform Providers (AWS, Azure, GCP...) β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Getting Started with CDKTF
# Install CDKTF
npm install -g cdktf-cli
# Create a new project
cdktf init --template="typescript" --providers="aws@~>5.0"
# Add providers
cdktf provider add "kubernetes@~>2.0"
# Synthesize (generate Terraform JSON)
cdktf synth
# Deploy (runs terraform apply)
cdktf deploy
# Destroy
cdktf destroy
CDKTF Example: VPC and EC2
import { Construct } from "constructs";
import { App, TerraformStack, TerraformOutput } from "cdktf";
import { AwsProvider } from "@cdktf/provider-aws/lib/provider";
import { Vpc } from "@cdktf/provider-aws/lib/vpc";
import { Subnet } from "@cdktf/provider-aws/lib/subnet";
import { InternetGateway } from "@cdktf/provider-aws/lib/internet-gateway";
import { RouteTable } from "@cdktf/provider-aws/lib/route-table";
import { RouteTableAssociation } from "@cdktf/provider-aws/lib/route-table-association";
import { Route } from "@cdktf/provider-aws/lib/route";
import { SecurityGroup } from "@cdktf/provider-aws/lib/security-group";
import { Instance } from "@cdktf/provider-aws/lib/instance";
import { DataAwsAmi } from "@cdktf/provider-aws/lib/data-aws-ami";
class MyStack extends TerraformStack {
constructor(scope: Construct, id: string) {
super(scope, id);
// Configure AWS Provider
new AwsProvider(this, "AWS", {
region: "us-west-2",
});
// Create VPC
const vpc = new Vpc(this, "vpc", {
cidrBlock: "10.0.0.0/16",
enableDnsHostnames: true,
enableDnsSupport: true,
tags: {
Name: "cdktf-vpc",
},
});
// Create Internet Gateway
const igw = new InternetGateway(this, "igw", {
vpcId: vpc.id,
tags: {
Name: "cdktf-igw",
},
});
// Create Public Subnet
const publicSubnet = new Subnet(this, "public-subnet", {
vpcId: vpc.id,
cidrBlock: "10.0.1.0/24",
availabilityZone: "us-west-2a",
mapPublicIpOnLaunch: true,
tags: {
Name: "cdktf-public-subnet",
},
});
// Create Route Table
const publicRouteTable = new RouteTable(this, "public-rt", {
vpcId: vpc.id,
tags: {
Name: "cdktf-public-rt",
},
});
// Create Route to Internet Gateway
new Route(this, "internet-route", {
routeTableId: publicRouteTable.id,
destinationCidrBlock: "0.0.0.0/0",
gatewayId: igw.id,
});
// Associate Route Table with Subnet
new RouteTableAssociation(this, "public-rta", {
subnetId: publicSubnet.id,
routeTableId: publicRouteTable.id,
});
// Create Security Group
const securityGroup = new SecurityGroup(this, "sg", {
name: "cdktf-sg",
vpcId: vpc.id,
ingress: [
{
fromPort: 22,
toPort: 22,
protocol: "tcp",
cidrBlocks: ["0.0.0.0/0"],
},
{
fromPort: 80,
toPort: 80,
protocol: "tcp",
cidrBlocks: ["0.0.0.0/0"],
},
{
fromPort: 443,
toPort: 443,
protocol: "tcp",
cidrBlocks: ["0.0.0.0/0"],
},
],
egress: [
{
fromPort: 0,
toPort: 0,
protocol: "-1",
cidrBlocks: ["0.0.0.0/0"],
},
],
tags: {
Name: "cdktf-sg",
},
});
// Get Latest Amazon Linux 2 AMI
const ami = new DataAwsAmi(this, "ami", {
mostRecent: true,
owners: ["amazon"],
filter: [
{
name: "name",
values: ["amzn2-ami-hvm-*-x86_64-gp2"],
},
],
});
// Create EC2 Instance
const instance = new Instance(this, "web-server", {
ami: ami.id,
instanceType: "t3.micro",
subnetId: publicSubnet.id,
vpcSecurityGroupIds: [securityGroup.id],
associatePublicIpAddress: true,
userData: `#!/bin/bash
yum update -y
yum install -y httpd
systemctl start httpd
systemctl enable httpd
echo "Hello from CDKTF!
" > /var/www/html/index.html`,
tags: {
Name: "cdktf-web-server",
},
});
// Outputs
new TerraformOutput(this, "vpc_id", {
value: vpc.id,
});
new TerraformOutput(this, "public_ip", {
value: instance.publicIp,
});
new TerraformOutput(this, "instance_id", {
value: instance.id,
});
}
}
const app = new App();
new MyStack(app, "cdktf-aws");
app.synth();
CDKTF Constructs
CDKTF uses the Constructs library for abstraction:
import { Construct } from "constructs";
import { TerraformStack } from "cdktf";
import { SecurityGroup } from "@cdktf/provider-aws/lib/security-group";
interface WebServerSecurityGroupProps {
readonly vpcId: string;
readonly allowedCidrBlocks: string[];
readonly enableHttps?: boolean;
}
export class WebServerSecurityGroup extends Construct {
public readonly securityGroup: SecurityGroup;
constructor(
scope: Construct,
id: string,
props: WebServerSecurityGroupProps
) {
super(scope, id);
const ingressRules = [
{
fromPort: 22,
toPort: 22,
protocol: "tcp",
cidrBlocks: props.allowedCidrBlocks,
description: "SSH",
},
{
fromPort: 80,
toPort: 80,
protocol: "tcp",
cidrBlocks: ["0.0.0.0/0"],
description: "HTTP",
},
];
if (props.enableHttps) {
ingressRules.push({
fromPort: 443,
toPort: 443,
protocol: "tcp",
cidrBlocks: ["0.0.0.0/0"],
description: "HTTPS",
});
}
this.securityGroup = new SecurityGroup(this, "sg", {
name: `${id}-sg`,
vpcId: props.vpcId,
ingress: ingressRules,
egress: [
{
fromPort: 0,
toPort: 0,
protocol: "-1",
cidrBlocks: ["0.0.0.0/0"],
},
],
tags: {
Name: `${id}-sg`,
},
});
}
}
// Usage
const webSg = new WebServerSecurityGroup(this, "web", {
vpcId: vpc.id,
allowedCidrBlocks: ["10.0.0.0/8"],
enableHttps: true,
});
- Still generates Terraform JSONβadds a layer of abstraction
- Requires understanding of both CDKTF and Terraform concepts
- Debugging can be challenging (TypeScript β JSON β Terraform)
- Provider updates may lag behind native Terraform
Deep Dive: AWS CDK
AWS CDK is Amazon's official infrastructure-as-code framework. While it can target other clouds via CloudFormation custom resources, it's primarily designed for AWS-first organizations.
AWS CDK Architecture
# AWS CDK Workflow
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Your Code (TS/Python/Java/C#/Go) β
β βββββββββββββββ βββββββββββββββ βββββββββββββββ β
β β Constructsβ β Stacks β β Apps β β
β βββββββββββββββ βββββββββββββββ βββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β CDK CLI synthesizes code β
β β Generates CloudFormation Templates β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β AWS CloudFormation β
β (Deployment, Drift Detection, Stack Sets) β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β AWS Services β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Getting Started with AWS CDK
# Install CDK
npm install -g aws-cdk
# Create a new project
mkdir cdk-demo && cd cdk-demo
cdk init app --language=typescript
# Add constructs
npm install @aws-cdk/aws-ec2 @aws-cdk/aws-eks
# Bootstrap (create S3 bucket for assets)
cdk bootstrap aws://ACCOUNT_ID/REGION
# Synthesize
cdk synth
# Deploy
cdk deploy
# Destroy
cdk destroy
AWS CDK Example: Serverless API
import * as cdk from 'aws-cdk-lib';
import * as lambda from 'aws-cdk-lib/aws-lambda';
import * as apigw from 'aws-cdk-lib/aws-apigateway';
import * as dynamodb from 'aws-cdk-lib/aws-dynamodb';
import * as logs from 'aws-cdk-lib/aws-logs';
import { Construct } from 'constructs';
export class ServerlessApiStack extends cdk.Stack {
constructor(scope: Construct, id: string, props?: cdk.StackProps) {
super(scope, id, props);
// DynamoDB Table
const table = new dynamodb.Table(this, 'ItemsTable', {
partitionKey: { name: 'id', type: dynamodb.AttributeType.STRING },
billingMode: dynamodb.BillingMode.PAY_PER_REQUEST,
removalPolicy: cdk.RemovalPolicy.DESTROY,
pointInTimeRecovery: true,
});
// Lambda Function
const handler = new lambda.Function(this, 'ItemsHandler', {
runtime: lambda.Runtime.NODEJS_20_X,
handler: 'index.handler',
code: lambda.Code.fromAsset('lambda'),
environment: {
TABLE_NAME: table.tableName,
},
logRetention: logs.RetentionDays.ONE_WEEK,
tracing: lambda.Tracing.ACTIVE,
memorySize: 256,
timeout: cdk.Duration.seconds(10),
});
// Grant permissions
table.grantReadWriteData(handler);
// API Gateway
const api = new apigw.RestApi(this, 'ItemsApi', {
restApiName: 'Items Service',
description: 'This service serves items.',
deployOptions: {
stageName: 'prod',
tracingEnabled: true,
loggingLevel: apigw.MethodLoggingLevel.INFO,
dataTraceEnabled: true,
},
defaultCorsPreflightOptions: {
allowOrigins: apigw.Cors.ALL_ORIGINS,
allowMethods: apigw.Cors.ALL_METHODS,
},
});
// API Endpoints
const items = api.root.addResource('items');
items.addMethod('GET', new apigw.LambdaIntegration(handler, {
proxy: true,
}), {
methodResponses: [{
statusCode: '200',
responseModels: {
'application/json': apigw.Model.EMPTY_MODEL,
},
}],
});
items.addMethod('POST', new apigw.LambdaIntegration(handler));
const singleItem = items.addResource('{id}');
singleItem.addMethod('GET', new apigw.LambdaIntegration(handler));
singleItem.addMethod('PUT', new apigw.LambdaIntegration(handler));
singleItem.addMethod('DELETE', new apigw.LambdaIntegration(handler));
// Outputs
new cdk.CfnOutput(this, 'APIEndpoint', {
value: api.url,
description: 'API Gateway endpoint URL',
});
new cdk.CfnOutput(this, 'TableName', {
value: table.tableName,
description: 'DynamoDB Table Name',
});
}
}
// Lambda code (lambda/index.js)
const AWS = require('aws-sdk');
const dynamodb = new AWS.DynamoDB.DocumentClient();
const TABLE_NAME = process.env.TABLE_NAME;
exports.handler = async (event) => {
const method = event.httpMethod;
const path = event.path;
try {
switch (method) {
case 'GET':
if (event.pathParameters?.id) {
// Get single item
const result = await dynamodb.get({
TableName: TABLE_NAME,
Key: { id: event.pathParameters.id },
}).promise();
return {
statusCode: 200,
body: JSON.stringify(result.Item),
};
} else {
// List items
const result = await dynamodb.scan({
TableName: TABLE_NAME,
}).promise();
return {
statusCode: 200,
body: JSON.stringify(result.Items),
};
}
case 'POST':
const item = JSON.parse(event.body);
item.id = Date.now().toString();
await dynamodb.put({
TableName: TABLE_NAME,
Item: item,
}).promise();
return {
statusCode: 201,
body: JSON.stringify(item),
};
default:
return {
statusCode: 405,
body: JSON.stringify({ error: 'Method not allowed' }),
};
}
} catch (error) {
return {
statusCode: 500,
body: JSON.stringify({ error: error.message }),
};
}
};
AWS CDK Constructs Library
AWS CDK's biggest strength is its extensive library of high-level constructs:
- L1 Constructs: Direct CloudFormation resource mappings (CfnBucket, CfnInstance)
- L2 Constructs: AWS-designed abstractions (Bucket, Instance, Function)
- L3 Constructs: Opinionated patterns (aws-ecs-patterns, aws-lambda-event-sources)
- Community Constructs: Third-party patterns (cdk-ecr-deployment, cdk-spot-one)
// L3 Construct Example: ECS Fargate Service with ALB
import * as ecs from 'aws-cdk-lib/aws-ecs';
import * as ecsp from 'aws-cdk-lib/aws-ecs-patterns';
new ecsp.ApplicationLoadBalancedFargateService(this, 'FargateService', {
taskImageOptions: {
image: ecs.ContainerImage.fromRegistry('amazon/amazon-ecs-sample'),
containerPort: 80,
},
desiredCount: 3,
cpu: 512,
memoryLimitMiB: 2048,
publicLoadBalancer: true,
enableExecuteCommand: true,
});
// This single construct creates:
// - ECS Cluster
// - Fargate Task Definition
// - Application Load Balancer
// - Security Groups
// - CloudWatch Log Group
// - Auto-scaling policies
- Rich library of high-level constructs for common patterns
- Native AWS integration and feature support
- CloudFormation benefits (drift detection, stack sets)
- Active community and extensive examples
- No additional cost (uses CloudFormation)
Real-World Examples
Let's compare how each tool handles a common scenario: deploying a containerized application with a database.
Scenario: Containerized Web App with PostgreSQL
Pulumi Implementation
import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";
import * as awsx from "@pulumi/awsx";
// VPC
const vpc = new awsx.ec2.Vpc("app-vpc", {
cidrBlock: "10.0.0.0/16",
numberOfAvailabilityZones: 2,
});
// Security Group for RDS
const dbSecurityGroup = new aws.ec2.SecurityGroup("db-sg", {
vpcId: vpc.vpcId,
ingress: [{
protocol: "tcp",
fromPort: 5432,
toPort: 5432,
securityGroups: [vpc.securityGroupIds[0]],
}],
});
// RDS Subnet Group
const dbSubnetGroup = new aws.rds.SubnetGroup("db-subnet-group", {
subnetIds: vpc.privateSubnetIds,
});
// RDS Instance
const db = new aws.rds.Instance("postgres", {
engine: "postgres",
engineVersion: "15",
instanceClass: "db.t3.micro",
allocatedStorage: 20,
dbName: "appdb",
username: "admin",
password: new pulumi.RandomPassword("db-password", {
length: 16,
special: true,
}).result,
vpcSecurityGroupIds: [dbSecurityGroup.id],
dbSubnetGroupName: dbSubnetGroup.name,
skipFinalSnapshot: true,
});
// ECS Cluster
const cluster = new aws.ecs.Cluster("app-cluster");
// ECR Repository
const repo = new aws.ecr.Repository("app-repo");
// ECS Task Definition
const taskDef = new aws.ecs.TaskDefinition("app-task", {
family: "app-task",
cpu: "256",
memory: "512",
networkMode: "awsvpc",
requiresCompatibilities: ["FARGATE"],
containerDefinitions: pulumi.all([db.endpoint, db.username, db.password]).apply(
([endpoint, username, password]) => JSON.stringify([{
name: "app",
image: repo.repositoryUrl,
portMappings: [{ containerPort: 3000 }],
environment: [
{ name: "DATABASE_URL", value: `postgres://${username}:${password}@${endpoint}/appdb` },
],
}])
),
});
// ECS Service with ALB
const service = new awsx.ecs.FargateService("app-service", {
cluster: cluster.arn,
taskDefinition: taskDef.arn,
desiredCount: 2,
networkConfiguration: {
subnets: vpc.publicSubnetIds,
securityGroups: [vpc.securityGroupIds[0]],
assignPublicIp: true,
},
loadBalancers: [{
targetGroupArn: alb.defaultTargetGroup.arn,
containerName: "app",
containerPort: 3000,
}],
});
export const appUrl = alb.loadBalancer.dnsName;
export const dbEndpoint = db.endpoint;
CDKTF Implementation
// Similar structure but generates Terraform JSON
// Uses Terraform AWS provider resources directly
// More verbose due to Terraform provider syntax
AWS CDK Implementation
import * as cdk from 'aws-cdk-lib';
import * as ec2 from 'aws-cdk-lib/aws-ec2';
import * as rds from 'aws-cdk-lib/aws-rds';
import * as ecs from 'aws-cdk-lib/aws-ecs';
import * as ecsp from 'aws-cdk-lib/aws-ecs-patterns';
import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager';
export class ContainerAppStack extends cdk.Stack {
constructor(scope: cdk.App, id: string, props?: cdk.StackProps) {
super(scope, id, props);
// VPC
const vpc = new ec2.Vpc(this, 'AppVpc', {
maxAzs: 2,
});
// Database credentials
const dbCredentials = new secretsmanager.Secret(this, 'DbCredentials', {
generateSecretString: {
secretStringTemplate: JSON.stringify({ username: 'admin' }),
generateStringKey: 'password',
excludeCharacters: '"@/\\',
},
});
// RDS Instance
const db = new rds.DatabaseInstance(this, 'Postgres', {
engine: rds.DatabaseInstanceEngine.postgres({
version: rds.PostgresEngineVersion.VER_15,
}),
instanceType: ec2.InstanceType.of(
ec2.InstanceClass.T3,
ec2.InstanceSize.MICRO
),
vpc,
vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_ISOLATED },
credentials: rds.Credentials.fromSecret(dbCredentials),
databaseName: 'appdb',
removalPolicy: cdk.RemovalPolicy.DESTROY,
});
// ECS Cluster
const cluster = new ecs.Cluster(this, 'Cluster', { vpc });
// Fargate Service with ALB
const fargateService = new ecsp.ApplicationLoadBalancedFargateService(
this,
'AppService',
{
cluster,
taskImageOptions: {
image: ecs.ContainerImage.fromAsset('./app'),
containerPort: 3000,
environment: {
DATABASE_URL: `postgres://${dbCredentials.secretValueFromJson('username').unsafeUnwrap()}:${dbCredentials.secretValueFromJson('password').unsafeUnwrap()}@${db.dbInstanceEndpointAddress}/appdb`,
},
},
desiredCount: 2,
cpu: 256,
memoryLimitMiB: 512,
publicLoadBalancer: true,
}
);
// Allow service to connect to database
db.connections.allowFrom(
fargateService.service,
ec2.Port.tcp(5432)
);
// Outputs
new cdk.CfnOutput(this, 'AppUrl', {
value: fargateService.loadBalancer.loadBalancerDnsName,
});
new cdk.CfnOutput(this, 'DbEndpoint', {
value: db.dbInstanceEndpointAddress,
});
}
}
Migration Strategies
Migrating from existing infrastructure to a new IaC tool requires careful planning. Here are proven strategies:
Strategy 1: Import Existing Resources
# Pulumi: Import existing resources
pulumi import aws:ec2/instance:Instance myInstance i-1234567890abcdef0
# CDKTF: Use terraform import
cdktf import
# AWS CDK: Use CfnInclude for existing CloudFormation
cdk import
Strategy 2: Strangler Fig Pattern
- Identify Boundaries: Find natural service boundaries in your existing infrastructure.
- Create New Stack: Build the new service using your chosen IaC tool.
- Migrate Traffic: Gradually shift traffic to the new infrastructure.
- Decommission Old: Remove the old infrastructure once migration is complete.
Strategy 3: Side-by-Side Deployment
Run new and old infrastructure in parallel:
# Example: Blue/Green with Pulumi
const config = new pulumi.Config();
const deploymentMode = config.get("mode") || "blue";
const blueStack = deploymentMode === "blue" ? createBlueStack() : undefined;
const greenStack = deploymentMode === "green" ? createGreenStack() : undefined;
// Use stack references to coordinate
export const activeEndpoint = deploymentMode === "blue"
? blueStack?.endpoint
: greenStack?.endpoint;
2026 Best Practices
General IaC Best Practices
- Version Control Everything: Store all infrastructure code in Git with clear commit messages
- Use Modules/Components: Build reusable abstractions for common patterns
- Environment Parity: Keep dev, staging, and prod as similar as possible
- Immutable Infrastructure: Prefer replacement over modification
- Secret Management: Never commit secrets; use dedicated secret stores
- Testing: Write unit and integration tests for infrastructure code
- Documentation: Document modules, inputs, outputs, and usage examples
Testing Strategies
// Pulumi: Unit testing with Jest
import * as pulumi from "@pulumi/pulumi";
import { describe, it, expect } from "@jest/globals";
pulumi.runtime.setMocks({
newResource: (args: pulumi.runtime.MockResourceArgs): {id: string, state: any} => {
return { id: args.inputs.name ? `${args.name}_id` : args.name + "_id", state: { ...args.inputs } };
},
call: (args: pulumi.runtime.MockCallArgs) => args.inputs,
});
describe("Infrastructure Tests", () => {
it("should create a VPC with correct CIDR", async () => {
const infra = await import("../index");
const vpcCidr = await pulumi.runtime.promiseResult(infra.vpc.cidrBlock);
expect(vpcCidr).toBe("10.0.0.0/16");
});
});
// AWS CDK: Testing with assertions
import { Template } from 'aws-cdk-lib/assertions';
import * as cdk from 'aws-cdk-lib';
import { MyStack } from '../lib/my-stack';
test('VPC Created with 2 AZs', () => {
const app = new cdk.App();
const stack = new MyStack(app, 'TestStack');
const template = Template.fromStack(stack);
template.hasResourceProperties('AWS::EC2::VPC', {
CidrBlock: '10.0.0.0/16',
});
template.resourceCountIs('AWS::EC2::Subnet', 4); // 2 public + 2 private
});
CI/CD Integration
# .github/workflows/iac.yml
name: Infrastructure as Code
on:
push:
paths:
- 'infrastructure/**'
pull_request:
paths:
- 'infrastructure/**'
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install dependencies
run: cd infrastructure && npm ci
- name: Run tests
run: cd infrastructure && npm test
- name: Lint
run: cd infrastructure && npm run lint
- name: Security scan
run: cd infrastructure && npm run security:check
preview:
needs: test
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.IAC_ROLE_ARN }}
aws-region: us-west-2
- name: Pulumi Preview
uses: pulumi/actions@v5
with:
command: preview
stack-name: staging
work-dir: ./infrastructure
comment-on-pr: true
env:
PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }}
deploy:
needs: test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
environment: production
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.IAC_ROLE_ARN }}
aws-region: us-west-2
- name: Pulumi Deploy
uses: pulumi/actions@v5
with:
command: up
stack-name: production
work-dir: ./infrastructure
env:
PULUMI_ACCESS_TOKEN: ${{ secrets.PULUMI_ACCESS_TOKEN }}
Decision Framework
Use this framework to choose the right tool for your situation:
| If You... | Choose | Why |
|---|---|---|
| Are multi-cloud or want flexibility | Pulumi | Best multi-cloud support, true programming model |
| Have existing Terraform investment | Terraform CDK | Leverage existing modules and state |
| Are AWS-first and want rich constructs | AWS CDK | Best AWS integration, extensive L3 constructs |
| Need strong testing capabilities | Pulumi or AWS CDK | Better testing frameworks and IDE support |
| Want lowest learning curve | AWS CDK (if AWS) / Pulumi | Better documentation and examples |
| Need enterprise support | Pulumi or Terraform CDK | Enterprise support options available |
Conclusion
The Infrastructure as Code landscape in 2026 offers more power and flexibility than ever before. Pulumi, Terraform CDK, and AWS CDK each bring unique strengths to the table.
Pulumi leads in multi-cloud flexibility and true programming language support. It's the best choice for teams that want to apply software engineering practices to infrastructure and need to work across multiple cloud providers.
Terraform CDK is the right choice for organizations with significant Terraform investments. It provides a migration path to programming languages while preserving existing modules and workflows.
AWS CDK is unbeatable for AWS-centric organizations. Its rich construct library and native integration make it the most productive choice for teams building on AWS.
The best tool is the one that fits your team's skills, your infrastructure requirements, and your operational constraints. All three are production-ready in 2026, and the gap between them continues to narrow. The important thing is to choose one, commit to it, and build expertise.
1. Audit your current infrastructure and pain points
2. Build a proof-of-concept with your top choice
3. Establish testing and CI/CD patterns
4. Migrate incrementally using the strangler fig pattern
5. Document patterns and build reusable components