Terraform uses HCL to define infrastructure — a domain-specific language. Developers must learn new syntax, handle string interpolation, and suffer limited IDE support. Pulumi lets you define infrastructure in real programming languages — TypeScript means type checking, IDE autocompletion, npm ecosystem, conditional logic, and loops. Everything you already know.
| Dimension |
Terraform (HCL) |
Pulumi (TypeScript) |
CDK (TypeScript) |
| Language |
HCL (DSL) |
TypeScript |
TypeScript |
| Type Safety |
None |
Full |
Full |
| IDE Support |
Limited |
Full |
Full |
| Conditional Logic |
count/for_each |
if/else/for |
if/else/for |
| Module Reuse |
Terraform Module |
npm package |
Construct |
| State Management |
State File |
Pulumi Cloud/Self-hosted |
CDK Cloud |
| Secret Management |
.tfvars |
Config secrets |
Context |
| Testing |
terraform test |
Mocha/Jest |
Jest |
| Dependency Mgmt |
None |
package.json |
package.json |
| Error Messages |
Runtime |
Compile-time + Runtime |
Compile-time + Runtime |
| Learning Curve |
Low (HCL simple) |
Low (familiar language) |
Medium (Construct abstraction) |
| Vendor Lock-in |
None |
Low |
AWS-bound |
Pulumi Architecture: Engine, Language Host, Resource Provider
┌─────────────────────────────────────────────────────┐
│ Pulumi CLI (pulumi) │
│ ┌────────────┐ ┌────────────┐ ┌────────────┐ │
│ │ Engine │ │ Language │ │ Resource │ │
│ │ (Orchest.) │──│ Host │──│ Provider │ │
│ │ │ │ (Node.js) │ │ (gRPC) │ │
│ └────────────┘ └────────────┘ └────────────┘ │
└──────┬───────────────────────────────────┬──────────┘
│ │
┌──────▼──────┐ ┌────────────┐ ┌────────▼────────┐
│ Pulumi Cloud │ │ AWS/GCP/ │ │ State Backend │
│ (optional) │ │ Azure API │ │ (local/S3/Cloud)│
└─────────────┘ └────────────┘ └─────────────────┘
Complete Project Setup with TypeScript
curl -fsSL https://get.pulumi.com | sh
pulumi login
mkdir my-infra && cd my-infra
pulumi new aws-typescript
package.json
{
"name": "my-infra",
"main": "index.ts",
"scripts": {
"preview": "pulumi preview",
"up": "pulumi up --yes",
"destroy": "pulumi destroy --yes",
"test": "mocha -r ts-node/register test/**/*.spec.ts"
},
"dependencies": {
"@pulumi/pulumi": "^3.130.0",
"@pulumi/aws": "^6.60.0",
"@pulumi/awsx": "^2.10.0"
},
"devDependencies": {
"@types/mocha": "^10.0.0",
"mocha": "^10.0.0",
"ts-node": "^10.9.0",
"typescript": "^5.5.0"
}
}
Deploy AWS Resources
VPC + ECS + RDS
import * as aws from "@pulumi/aws";
import * as awsx from "@pulumi/awsx";
import * as pulumi from "@pulumi/pulumi";
const env = pulumi.getStack();
const vpc = new awsx.ec2.Vpc("main-vpc", {
cidrBlock: "10.0.0.0/16",
subnets: [
{ type: "public", cidrMask: 24 },
{ type: "private", cidrMask: 24 },
],
tags: { Environment: env },
});
const dbSubnetGroup = new aws.rds.SubnetGroup("db-subnet", {
subnetIds: vpc.privateSubnetIds,
tags: { Environment: env },
});
const db = new aws.rds.Instance("app-db", {
engine: "postgres",
engineVersion: "16.4",
instanceClass: "db.t3.medium",
allocatedStorage: 100,
storageType: "gp3",
dbName: "appdb",
username: "appadmin",
password: pulumi.Config.requireSecret("db-password"),
dbSubnetGroupName: dbSubnetGroup.name,
vpcSecurityGroupIds: [vpc.vpc.defaultSecurityGroupId],
skipFinalSnapshot: true,
tags: { Environment: env },
});
const repo = new aws.ecr.Repository("app-repo", {
imageScanningConfiguration: { scanOnPush: true },
tags: { Environment: env },
});
const cluster = new aws.ecs.Cluster("app-cluster", {
tags: { Environment: env },
});
const appService = new awsx.ecs.FargateService("app-service", {
cluster: cluster.arn,
desiredCount: env === "prod" ? 3 : 1,
taskDefinitionArgs: {
container: {
name: "app",
image: repo.latestImageDigest,
portMappings: [{ containerPort: 8080 }],
environment: [
{ name: "DATABASE_URL", value: pulumi.interpolate`postgresql://${db.username}:${db.password}@${db.endpoint}/appdb` },
{ name: "NODE_ENV", value: env },
],
cpu: 256,
memory: 512,
},
},
subnetIds: vpc.privateSubnetIds,
securityGroups: [vpc.vpc.defaultSecurityGroupId],
});
export const dbEndpoint = db.endpoint;
export const ecrRepoUrl = repo.repositoryUrl;
Deploy GCP Resources
import * as gcp from "@pulumi/gcp";
const cluster = new gcp.container.Cluster("app-cluster", {
project: gcp.config.project,
location: gcp.config.region || "us-central1",
initialNodeCount: 3,
nodeConfig: {
machineType: "e2-medium",
oauthScopes: ["https://www.googleapis.com/auth/cloud-platform"],
},
});
const db = new gcp.sql.DatabaseInstance("app-db", {
project: gcp.config.project,
region: gcp.config.region || "us-central1",
databaseVersion: "POSTGRES_16",
settings: { tier: "db-f1-micro", diskSize: 20 },
});
export const clusterName = cluster.name;
export const dbName = db.name;
Deploy Azure Resources
import * as azure from "@pulumi/azure";
const resourceGroup = new azure.core.ResourceGroup("app-rg", {
location: "East Asia",
tags: { Environment: pulumi.getStack() },
});
const aks = new azure.containerservice.KubernetesCluster("app-aks", {
resourceGroupName: resourceGroup.name,
location: resourceGroup.location,
defaultNodePool: {
name: "default",
nodeCount: 3,
vmSize: "Standard_D2s_v3",
},
identity: { type: "SystemAssigned" },
});
export const aksFqdn = aks.fqdn;
Stack Management and Configuration
pulumi stack init dev
pulumi stack init prod
pulumi config set aws:region us-east-1
pulumi config set db-password "secret-value" --secret
pulumi stack select prod
pulumi preview
pulumi up
pulumi stack output apiUrl
Using Configuration in Code
const config = new pulumi.Config();
const instanceCount = config.requireNumber("instance-count");
const dbPassword = config.requireSecret("db-password");
const isProd = pulumi.getStack() === "prod";
const dbInstanceClass = isProd ? "db.r6g.xlarge" : "db.t3.medium";
Testing Infrastructure with Pulumi Testing Framework
Unit Tests
import * as pulumi from "@pulumi/pulumi";
import { describe, it, expect } from "mocha";
pulumi.runtime.setMocks({
newResource: (args) => ({
id: `${args.name}-id`,
state: args.inputs,
}),
call: (args) => args.inputs,
});
describe("Infrastructure", () => {
it("should use larger DB in production", () => {
const isProd = true;
const dbInstanceClass = isProd ? "db.r6g.xlarge" : "db.t3.medium";
expect(dbInstanceClass).to.equal("db.r6g.xlarge");
});
it("should create ECS service with correct desired count", () => {
const env = "prod";
const desiredCount = env === "prod" ? 3 : 1;
expect(desiredCount).to.equal(3);
});
});
Policy Tests
import { PolicyPack, validateResource } from "@pulumi/policy";
new PolicyPack("infra-policies", {
policies: [
{
name: "no-public-s3-buckets",
description: "S3 buckets must not be publicly accessible",
enforcementLevel: "mandatory",
validateResource: validateResourceOfType(aws.s3.Bucket, (bucket, args) => {
if (args.acl === "public-read") {
return "S3 bucket must not be publicly accessible";
}
}),
},
{
name: "required-tags",
description: "All resources must have Environment tag",
enforcementLevel: "mandatory",
validateResource: (args) => {
const tags = args.props.tags;
if (!tags || !tags.Environment) {
return "All resources must have an Environment tag";
}
},
},
],
});
| Dimension |
Pulumi |
Terraform |
CDK |
| Languages |
TS/Python/Go/C# |
HCL |
TS/Python/Java/C# |
| State |
Pulumi Cloud/Self-hosted |
State File |
CDK Cloud |
| Providers |
200+ |
3000+ |
AWS-primary |
| Testing |
Native |
terraform test |
Jest |
| Secrets |
Config secrets |
SOPS/Vault |
Context |
| Import Existing |
pulumi import |
terraform import |
cdk import |
| Multi-cloud |
Native |
Native |
AWS-primary |
| Business Model |
Open Source + Cloud |
Open Source + Cloud |
Open Source |
5 Common Pitfalls
1. Output Values Not Available at Runtime
// Wrong
const endpoint = db.endpoint;
console.log(endpoint); // Promise, not actual value
// Correct
const dbUrl = pulumi.interpolate`postgresql://${db.endpoint}/appdb`;
db.endpoint.apply(ep => console.log(`DB endpoint: ${ep}`));
2. Resource Dependencies Not Correctly Declared
const app = new aws.ecs.Service("app", {
// ...
}, { dependsOn: [db] });
3. Storing Secrets in Plain Text
# Wrong
pulumi config set db-password "my-secret"
# Correct
pulumi config set db-password "my-secret" --secret
4. Ignoring Destroy Protection
const db = new aws.rds.Instance("app-db", {
deletionProtection: true,
skipFinalSnapshot: false,
finalSnapshotIdentifier: `app-db-final-${Date.now()}`,
});
5. Race Conditions from Parallel Deploys
pulumi login # Pulumi Cloud manages locks automatically
10 Error Troubleshooting
| # |
Symptom |
Possible Cause |
Resolution |
| 1 |
requireSecret returns encrypted value |
Used outside apply |
Use apply or interpolate |
| 2 |
Resource creation timeout |
Cloud API rate limiting |
Check parallelism, add retry |
| 3 |
Provider version incompatible |
Outdated package.json |
pulumi plugin install |
| 4 |
Stack state corrupted |
Interrupted deploy |
pulumi state unprotect |
| 5 |
Type errors |
TypeScript type mismatch |
tsc --noEmit |
| 6 |
Circular dependency |
Resources reference each other |
Restructure dependency graph |
| 7 |
Missing configuration |
Stack config not set |
pulumi config |
| 8 |
Insufficient permissions |
Cloud credentials lack perms |
Check IAM roles |
| 9 |
Drift detected |
Manual cloud resource changes |
pulumi refresh |
| 10 |
Import existing resource fails |
Incorrect resource ID |
Check ID format |
pulumi stack
pulumi preview --diff
pulumi stack history
pulumi refresh
pulumi stack --show-resources
Summary: Pulumi + TypeScript lets developers define infrastructure in familiar programming languages, enjoying type safety, IDE support, and the npm ecosystem. In 2026, Pulumi supports 200+ providers covering AWS, GCP, Azure, and more. Key practices: handle Output's async nature correctly, use --secret for sensitive configs, enable deletion protection, write Policy checks for compliance, and use the Testing Framework to validate infrastructure logic. For developers familiar with TypeScript, Pulumi is a more natural and efficient IaC choice than Terraform.