Infrastructure as Code with Pulumi and TypeScript: Manage Cloud Resources Like Real Code

DevOps tutorial - IT technology blog
DevOps tutorial - IT technology blog

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 for loops to create multiple S3 buckets, interfaces to type-check your config, or async/await to 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 metadata
  • Pulumi.dev.yaml — stack-specific config
  • index.ts — your infrastructure code
  • package.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.

Share: