Pulumi and TypeScript for Cloud Infrastructure
A hands-on guide to managing cloud infrastructure with Pulumi and TypeScript: resource composition, state, secrets handling and testing infrastructure code.

Why TypeScript for Infrastructure
YAML and HCL were designed to be declarative configuration languages. They work until you need conditionals, loops, abstractions, or type safety—at which point you are fighting the language instead of solving the infrastructure problem. Pulumi uses real programming languages for infrastructure definitions, which means TypeScript's type system, IDE support, testing frameworks, and package ecosystem all apply directly to cloud resources.
The shift is not cosmetic. It changes how you think about infrastructure: not as static configuration but as composable, testable code.
Defining Resources with Type Safety
import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";
// ❌ YAML/HCL — no type checking, no autocomplete, string-based references
// resource "aws_s3_bucket" "data" {
// bucket = "my-data-bucket"
// acl = "privte" ← typo undetected until apply
// }
// ✅ TypeScript — type errors caught at compile time
const dataBucket = new aws.s3.Bucket("data-bucket", {
bucket: "my-data-bucket",
acl: "private", // Autocomplete shows valid values
versioning: { enabled: true },
serverSideEncryptionConfiguration: {
rule: {
applyServerSideEncryptionByDefault: {
sseAlgorithm: "aws:kms",
},
},
},
lifecycleRules: [
{
enabled: true,
transitions: [
{ days: 30, storageClass: "STANDARD_IA" },
{ days: 90, storageClass: "GLACIER" },
],
expiration: { days: 365 },
},
],
});
// Output the bucket ARN — typed as pulumi.Output<string>
export const bucketArn = dataBucket.arn;Composing Reusable Components
Real programming languages let you build abstractions. A ComponentResource encapsulates related resources into a reusable module with a typed interface—something impossible in declarative-only tools without third-party templating.
interface WebAppArgs {
domain: string;
environment: "staging" | "production";
containerImage: string;
cpu: number;
memory: number;
desiredCount: number;
healthCheckPath: string;
}
class WebApp extends pulumi.ComponentResource {
public readonly url: pulumi.Output<string>;
public readonly serviceName: pulumi.Output<string>;
constructor(
name: string,
args: WebAppArgs,
opts?: pulumi.ComponentResourceOptions
) {
super("custom:WebApp", name, {}, opts);
const cluster = new aws.ecs.Cluster(`${name}-cluster`, {}, { parent: this });
const taskDef = new aws.ecs.TaskDefinition(
`${name}-task`,
{
family: name,
cpu: String(args.cpu),
memory: String(args.memory),
networkMode: "awsvpc",
requiresCompatibilities: ["FARGATE"],
containerDefinitions: JSON.stringify([
{
name: name,
image: args.containerImage,
portMappings: [{ containerPort: 3000 }],
healthCheck: {
command: ["CMD-SHELL", `curl -f http://localhost:3000${args.healthCheckPath} || exit 1`],
interval: 30,
timeout: 5,
retries: 3,
},
},
]),
},
{ parent: this }
);
const service = new aws.ecs.Service(
`${name}-service`,
{
cluster: cluster.arn,
taskDefinition: taskDef.arn,
desiredCount: args.desiredCount,
launchType: "FARGATE",
},
{ parent: this }
);
this.url = pulumi.interpolate`https://${args.domain}`;
this.serviceName = service.name;
this.registerOutputs({
url: this.url,
serviceName: this.serviceName,
});
}
}
// Usage — deploy two environments with one component
const staging = new WebApp("api-staging", {
domain: "staging.api.example.com",
environment: "staging",
containerImage: "registry.example.com/api:latest",
cpu: 256,
memory: 512,
desiredCount: 1,
healthCheckPath: "/health",
});
const production = new WebApp("api-production", {
domain: "api.example.com",
environment: "production",
containerImage: "registry.example.com/api:v2.3.1",
cpu: 1024,
memory: 2048,
desiredCount: 3,
healthCheckPath: "/health",
});Secrets and Configuration Management
Pulumi encrypts secrets in state by default. Configuration values are typed and stack-scoped, so the same code deploys to different environments without conditionals scattered through the definitions.
const config = new pulumi.Config();
// Plaintext config
const region = config.require("region");
const environment = config.require("environment");
// Encrypted secrets — never stored in plaintext in state
const dbPassword = config.requireSecret("dbPassword");
const apiKey = config.requireSecret("apiKey");
// Type-safe configuration objects
interface AppConfig {
replicas: number;
logLevel: string;
features: string[];
}
const appConfig = config.requireObject<AppConfig>("app");
// Secrets are pulumi.Output<string> — can only be used in resource args
const database = new aws.rds.Instance("main-db", {
instanceClass: "db.t3.medium",
allocatedStorage: 20,
engine: "postgres",
engineVersion: "16",
masterUsername: "admin",
masterPassword: dbPassword, // Encrypted in state
skipFinalSnapshot: environment !== "production",
});Testing Infrastructure Code
Because infrastructure is TypeScript, you can write unit tests that verify resource configurations without deploying anything. Pulumi's mocking framework replaces cloud API calls with assertions.
import * as pulumi from "@pulumi/pulumi";
import { describe, it, expect, beforeAll } from "vitest";
// Mock Pulumi runtime for testing
pulumi.runtime.setMocks({
newResource: (args) => ({
id: `${args.name}-id`,
state: args.inputs,
}),
call: (args) => args.inputs,
});
describe("WebApp component", () => {
let app: typeof import("./index");
beforeAll(async () => {
app = await import("./index");
});
it("creates ECS service with correct desired count", (done) => {
pulumi.all([app.production.serviceName]).apply(([name]) => {
expect(name).toBeDefined();
done();
});
});
it("uses FARGATE launch type", (done) => {
// Verify resource properties match expectations
const resources = pulumi.runtime.listResourceOutputs();
// Assert against collected resources
done();
});
});
// Policy tests — enforce organizational rules
import { PolicyPack, validateResourceOfType } from "@pulumi/policy";
new PolicyPack("security-policies", {
policies: [
{
name: "s3-no-public-read",
description: "S3 buckets must not have public read access",
enforcementLevel: "mandatory",
validateResource: validateResourceOfType(
aws.s3.Bucket,
(bucket, args, reportViolation) => {
if (bucket.acl === "public-read" || bucket.acl === "public-read-write") {
reportViolation("S3 buckets must not be publicly readable");
}
}
),
},
{
name: "rds-encryption-required",
description: "RDS instances must have storage encryption enabled",
enforcementLevel: "mandatory",
validateResource: validateResourceOfType(
aws.rds.Instance,
(instance, args, reportViolation) => {
if (!instance.storageEncrypted) {
reportViolation("RDS instances must enable storage encryption");
}
}
),
},
],
});Stack References for Multi-Stack Architectures
Large infrastructure splits into multiple stacks—networking, compute, databases—each managed independently. Stack references let one stack consume outputs from another without hardcoded values.
// Networking stack exports VPC and subnet IDs
export const vpcId = vpc.id;
export const privateSubnetIds = privateSubnets.map((s) => s.id);
// Application stack references networking outputs
const networkStack = new pulumi.StackReference("org/networking/production");
const vpcId = networkStack.getOutput("vpcId");
const subnetIds = networkStack.getOutput("privateSubnetIds");
const service = new aws.ecs.Service("app", {
networkConfiguration: {
subnets: subnetIds as pulumi.Output<string[]>,
securityGroups: [appSecurityGroup.id],
},
});Key Takeaways
Using TypeScript for infrastructure gives you type safety, IDE autocomplete, refactoring tools, and real abstractions—none of which are available in YAML or HCL. Type errors in resource configurations are caught at compile time, not during a 10-minute apply cycle.
Build reusable ComponentResource classes that encapsulate related resources behind typed interfaces. This eliminates copy-paste between environments and enforces consistent configurations. Use Pulumi's config system for environment-specific values and its secret encryption for credentials.
Test infrastructure code the same way you test application code: unit tests for resource configuration, policy tests for organizational rules, and integration tests for end-to-end stack deployment. The investment in testable infrastructure pays back every time a configuration error is caught before it reaches production.


