Why YAML Files Keep Causing Problems
If you’ve spent any time managing cloud infrastructure, you’ve probably run into this: a Terraform file that works perfectly on your machine breaks in staging because someone forgot to update a variable. Or a CloudFormation template that’s 800 lines of YAML with no way to loop, abstract, or test anything properly.
The root cause isn’t the tools themselves. Declarative DSLs like HCL and YAML have hard limits — they’re not real programming languages. You can’t write a function, run unit tests, or import a library. Every time your infrastructure gets complex, you end up wrestling with the language instead of solving the actual problem.
Pulumi takes a different approach: write infrastructure using TypeScript, Python, Go, or C#. Real languages, real type systems, real IDE support. I’ve used it across four separate AWS environments in production — on teams ranging from 3 to 15 engineers — and it’s held up well, especially when the team already knows TypeScript from their app work.
How Pulumi Actually Works
Pulumi isn’t just “Terraform but with TypeScript.” The model is genuinely different:
- State management: Pulumi tracks state via Pulumi Cloud (or self-hosted backends like S3). No manual state file juggling.
- Real language features: Use
forloops to create multiple S3 buckets, interfaces to type-check your config, orasync/awaitto compose resources that depend on each other. - Component resources: Bundle a VPC + subnets + security groups into a reusable class. Call it across stacks like any other module.
- Policy as Code: Enforce rules (“no public S3 buckets”) programmatically before deployment.
One honest tradeoff: if your team has zero programming background, the ramp-up takes longer than Terraform. But for anyone already writing TypeScript day-to-day, the mental overhead is minimal — usually a few hours to get comfortable, not days.
Installation
Prerequisites
You’ll need Node.js 18+ and the Pulumi CLI. On macOS or Linux:
# Install Pulumi CLI
curl -fsSL https://get.pulumi.com | sh
# Verify
pulumi version
On Windows, use winget:
winget install pulumi
Next, configure the AWS CLI for whichever region you’re targeting:
aws configure
# Enter your Access Key ID, Secret, region (e.g. ap-northeast-1)
Create a New Pulumi Project
mkdir my-infra && cd my-infra
pulumi new aws-typescript
This scaffolds a project with:
Pulumi.yaml— project metadataPulumi.dev.yaml— stack-specific configindex.ts— your infrastructure codepackage.json— Node.js dependencies
npm install
Writing Your First Resources
An S3 Bucket with Versioning
Open index.ts. The default scaffold already creates an S3 bucket — here’s a cleaner version with versioning and tags:
import * as aws from "@pulumi/aws";
const bucket = new aws.s3.Bucket("my-app-bucket", {
versioning: {
enabled: true,
},
tags: {
Environment: "production",
ManagedBy: "pulumi",
},
});
export const bucketName = bucket.id;
export const bucketArn = bucket.arn;
The export lines expose outputs — query them later with pulumi stack output bucketName.
Loops Instead of Copy-Paste
Need three S3 buckets for different environments? In Terraform, you’d reach for count or for_each and fight with index syntax. In Pulumi, it’s just a map:
import * as aws from "@pulumi/aws";
const environments = ["dev", "staging", "production"];
const buckets = environments.map(env =>
new aws.s3.Bucket(`app-bucket-${env}`, {
versioning: { enabled: env === "production" },
tags: { Environment: env },
})
);
export const bucketNames = buckets.map(b => b.id);
Three resources, zero repetition. The versioning logic — enabled only in production — is a single conditional. No workarounds needed.
A More Complete Example: EC2 with Security Group
import * as aws from "@pulumi/aws";
import * as pulumi from "@pulumi/pulumi";
const config = new pulumi.Config();
const instanceType = config.get("instanceType") || "t3.micro";
// Security group
const sg = new aws.ec2.SecurityGroup("web-sg", {
description: "Allow HTTP and SSH",
ingress: [
{ protocol: "tcp", fromPort: 22, toPort: 22, cidrBlocks: ["0.0.0.0/0"] }, // demo only — restrict to your IP in prod
{ protocol: "tcp", fromPort: 80, toPort: 80, cidrBlocks: ["0.0.0.0/0"] },
],
egress: [
{ protocol: "-1", fromPort: 0, toPort: 0, cidrBlocks: ["0.0.0.0/0"] },
],
});
// Latest Amazon Linux 2 AMI
const ami = aws.ec2.getAmi({
mostRecent: true,
owners: ["amazon"],
filters: [{ name: "name", values: ["amzn2-ami-hvm-*-x86_64-gp2"] }],
});
// EC2 instance
const server = new aws.ec2.Instance("web-server", {
instanceType: instanceType,
ami: ami.then(a => a.id),
vpcSecurityGroupIds: [sg.id],
tags: { Name: "pulumi-web-server" },
});
export const publicIp = server.publicIp;
export const publicDns = server.publicDns;
pulumi.Config() reads from your stack config file. Set a value with:
pulumi config set instanceType t3.small
Same code, different instance size per stack. No source changes, no variable file gymnastics.
Stacks: One Codebase, Multiple Environments
# Create a staging stack
pulumi stack init staging
pulumi config set instanceType t3.small
# Switch back to dev
pulumi stack select dev
Each stack gets its own state and config, and deploys independently. It replaces the painful pattern of copy-pasting Terraform directories per environment — something every team eventually regrets.
Deploying and Verifying
Preview Before Applying
Always run a preview first:
pulumi preview
Similar to terraform plan, this shows exactly what Pulumi will create, update, or destroy — with property-level diffs, not just resource names.
Deploy
pulumi up
Pulumi prompts for confirmation, then applies. Output looks like:
Updating (dev):
Type Name Status
+ pulumi:pulumi:Stack my-infra-dev created
+ aws:ec2:SecurityGroup web-sg created
+ aws:ec2:Instance web-server created
Outputs:
publicIp: "54.123.45.67"
publicDns: "ec2-54-123-45-67.ap-northeast-1.compute.amazonaws.com"
Resources:
+ 3 created
Duration: 42s
Check Stack Outputs
pulumi stack output publicIp
# 54.123.45.67
pulumi stack output --json
# { "publicDns": "...", "publicIp": "54.123.45.67" }
View the Resource Graph
Pulumi Cloud’s free tier gives you a visual resource graph, deployment history, and per-update diffs. Prefer keeping state in your own infrastructure? Point it at an S3 bucket:
pulumi login s3://your-state-bucket
Tear Down
pulumi destroy
Cleans up everything tracked in the stack’s state. No orphaned resources from forgotten console clicks.
Unit Testing Infrastructure
Because it’s TypeScript, you can write proper unit tests. Pulumi’s testing SDK lets you mock resource outputs without touching real AWS:
npm install --save-dev @pulumi/pulumi mocha ts-node @types/mocha
// infra.test.ts
import * as pulumi from "@pulumi/pulumi";
pulumi.runtime.setMocks({
newResource: (args) => ({ id: `${args.name}-id`, state: args.inputs }),
call: (args) => ({ outputs: {} }),
});
describe("S3 Bucket", () => {
it("should have versioning enabled in production", async () => {
const infra = await import("./index");
// Add assertions here
});
});
Running mocha against your infrastructure code catches configuration bugs before anything touches a real account. That alone has saved me more than a few Friday afternoon incidents.
Where to Go From Here
The patterns above handle the majority of day-to-day IaC work. Once you’re comfortable, a few areas are worth exploring next:
- Pulumi Component Resources: build reusable infrastructure packages (a “VPC module” as a TypeScript class)
- CrossWalk for AWS: higher-level AWS abstractions that bake in best practices by default
- Automation API: embed Pulumi in your own backend application to drive deployments programmatically
- Policy as Code (CrossGuard): define and enforce infrastructure rules before anything gets deployed
The switch from YAML-based tools to a real programming language is disorienting for a day or two — then something clicks. After that, going back to raw HCL or CloudFormation feels like editing XML by hand. Some things you just can’t un-learn.

