Pulumi for Infrastructure as Code with Real Programming Languages
Learn how Pulumi lets you define cloud infrastructure using TypeScript, Python, or Go instead of domain-specific languages.
What you'll learn
- ✓How Pulumi differs from Terraform and CloudFormation
- ✓Defining AWS infrastructure with TypeScript
- ✓Using loops, conditionals, and abstractions for DRY infrastructure
- ✓Managing state, stacks, and secrets in Pulumi
Prerequisites
- •Basic cloud concepts (VPCs, EC2, S3)
- •Familiarity with TypeScript or Python
Why Pulumi
Terraform uses HCL, a domain-specific language that works well until you need a loop with conditional logic, string manipulation, or a reusable abstraction with type checking. HCL can do some of these things, but awkwardly.
Pulumi takes a different approach. You write infrastructure code in TypeScript, Python, Go, C#, or Java. You get IDE autocompletion, type safety, unit testing with your existing test framework, and the full power of a general-purpose language. If you can write it in TypeScript, you can use it to define infrastructure.
Under the hood, Pulumi works similarly to Terraform. It maintains a state file, computes a diff between desired and actual state, and makes API calls to cloud providers to reconcile the difference.
Getting Started
# Install Pulumi
curl -fsSL https://get.pulumi.com | sh
# Create a new project
mkdir my-infra && cd my-infra
pulumi new aws-typescript
# This generates:
# - Pulumi.yaml (project config)
# - Pulumi.dev.yaml (stack config)
# - index.ts (your infrastructure code)
# - package.json
# - tsconfig.json
Defining Resources
Here is a basic S3 bucket and a Lambda function in TypeScript:
// index.ts
import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";
// Create an S3 bucket
const bucket = new aws.s3.Bucket("data-bucket", {
versioning: {
enabled: true,
},
serverSideEncryptionConfiguration: {
rule: {
applyServerSideEncryptionByDefault: {
sseAlgorithm: "AES256",
},
},
},
tags: {
Environment: pulumi.getStack(),
ManagedBy: "pulumi",
},
});
// Create an IAM role for Lambda
const lambdaRole = new aws.iam.Role("api-lambda-role", {
assumeRolePolicy: JSON.stringify({
Version: "2012-10-17",
Statement: [
{
Action: "sts:AssumeRole",
Principal: { Service: "lambda.amazonaws.com" },
Effect: "Allow",
},
],
}),
});
// Attach basic execution policy
new aws.iam.RolePolicyAttachment("lambda-basic-execution", {
role: lambdaRole.name,
policyArn: aws.iam.ManagedPolicy.AWSLambdaBasicExecutionRole,
});
// Create a Lambda function
const apiFunction = new aws.lambda.Function("api-handler", {
runtime: "nodejs20.x",
handler: "index.handler",
role: lambdaRole.arn,
code: new pulumi.asset.AssetArchive({
".": new pulumi.asset.FileArchive("./lambda"),
}),
environment: {
variables: {
BUCKET_NAME: bucket.id,
},
},
});
// Export outputs
export const bucketName = bucket.id;
export const functionArn = apiFunction.arn;
Run pulumi up to preview and apply changes. Pulumi shows a diff of what will be created, updated, or deleted, just like terraform plan.
The Power of Real Code
Here is where Pulumi shines. You can use standard programming constructs:
Loops
const azs = ["us-east-1a", "us-east-1b", "us-east-1c"];
const subnets = azs.map((az, index) =>
new aws.ec2.Subnet(`subnet-${az}`, {
vpcId: vpc.id,
cidrBlock: `10.0.${index}.0/24`,
availabilityZone: az,
tags: {
Name: `app-subnet-${az}`,
Tier: "private",
},
})
);
Conditionals
const config = new pulumi.Config();
const enableMonitoring = config.getBoolean("enableMonitoring") ?? false;
if (enableMonitoring) {
new aws.cloudwatch.MetricAlarm("high-cpu", {
comparisonOperator: "GreaterThanThreshold",
evaluationPeriods: 2,
metricName: "CPUUtilization",
namespace: "AWS/EC2",
period: 300,
statistic: "Average",
threshold: 80,
alarmActions: [snsTopic.arn],
});
}
Reusable Abstractions
// components/web-service.ts
import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";
interface WebServiceArgs {
image: string;
port: number;
cpu: number;
memory: number;
desiredCount: number;
subnetIds: pulumi.Input<string>[];
vpcId: pulumi.Input<string>;
}
export class WebService extends pulumi.ComponentResource {
public readonly url: pulumi.Output<string>;
constructor(name: string, args: WebServiceArgs, opts?: pulumi.ComponentResourceOptions) {
super("custom:WebService", name, {}, opts);
const taskDef = new aws.ecs.TaskDefinition(`${name}-task`, {
family: name,
networkMode: "awsvpc",
requiresCompatibilities: ["FARGATE"],
cpu: String(args.cpu),
memory: String(args.memory),
containerDefinitions: JSON.stringify([
{
name: name,
image: args.image,
portMappings: [{ containerPort: args.port }],
essential: true,
},
]),
}, { parent: this });
const service = new aws.ecs.Service(`${name}-service`, {
cluster: cluster.arn,
taskDefinition: taskDef.arn,
desiredCount: args.desiredCount,
launchType: "FARGATE",
networkConfiguration: {
subnets: args.subnetIds,
assignPublicIp: false,
},
}, { parent: this });
this.url = pulumi.interpolate`http://${loadBalancer.dnsName}`;
this.registerOutputs({ url: this.url });
}
}
Now creating a service is a single line:
// index.ts
import { WebService } from "./components/web-service";
const api = new WebService("api", {
image: "registry.example.com/api:v1.2.0",
port: 8080,
cpu: 512,
memory: 1024,
desiredCount: 3,
subnetIds: privateSubnetIds,
vpcId: vpc.id,
});
export const apiUrl = api.url;
This is a typed, composable, testable abstraction. Try doing this in HCL.
Stacks and Configuration
Pulumi uses stacks to manage multiple environments:
# Create stacks
pulumi stack init staging
pulumi stack init production
# Set per-stack configuration
pulumi config set aws:region us-east-1
pulumi config set instanceCount 2 --stack staging
pulumi config set instanceCount 5 --stack production
# Set secrets (encrypted in state)
pulumi config set --secret dbPassword 'sup3r-s3cret'
Access configuration in code:
const config = new pulumi.Config();
const instanceCount = config.requireNumber("instanceCount");
const dbPassword = config.requireSecret("dbPassword");
Secrets are encrypted in the stack configuration file. Pulumi supports multiple encryption backends: Pulumi Cloud, AWS KMS, GCP KMS, Azure Key Vault, or a local passphrase.
Testing
Since your infrastructure is real code, you can write real tests:
// index.test.ts
import * as pulumi from "@pulumi/pulumi";
import "jest";
// Mock Pulumi runtime
pulumi.runtime.setMocks({
newResource: (args: pulumi.runtime.MockResourceArgs) => {
return { id: `${args.name}-id`, state: args.inputs };
},
call: (args: pulumi.runtime.MockCallArgs) => {
return args.inputs;
},
});
describe("Infrastructure", () => {
let infra: typeof import("./index");
beforeAll(async () => {
infra = await import("./index");
});
test("bucket has versioning enabled", (done) => {
pulumi.all([infra.bucketName]).apply(([name]) => {
expect(name).toBeDefined();
done();
});
});
});
You can also use policy-as-code with Pulumi CrossGuard to enforce rules across all stacks:
new PolicyPack("aws-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 bucket must not be publicly readable");
}
}),
},
],
});
State Management
Pulumi stores state in a backend. Options include:
- Pulumi Cloud (default): managed, includes history, RBAC, and drift detection.
- S3/GCS/Azure Blob: self-managed, you handle locking and encryption.
- Local file: for development only.
# Use S3 backend
pulumi login s3://my-pulumi-state-bucket
# Use Pulumi Cloud
pulumi login
When to Choose Pulumi Over Terraform
Choose Pulumi when your team is already proficient in TypeScript, Python, or Go and wants to leverage that expertise. Choose it when your infrastructure logic requires complex conditionals, loops, or abstractions that feel awkward in HCL. Choose it when you want to test infrastructure with your existing test framework.
Stick with Terraform when your team knows HCL well, when you rely on a large ecosystem of community modules, or when your organization has already invested heavily in Terraform tooling and workflows.
Both tools solve the same fundamental problem. The best choice depends on your team’s skills and existing investment.
Related articles
- DevOps Terraform Modules for Reusable Infrastructure
Learn how to build, structure, and publish Terraform modules to create reusable, composable infrastructure as code.
- DevOps Infrastructure as Code: Terraform Getting Started Guide
Learn Infrastructure as Code with Terraform. This beginner guide covers HCL syntax, providers, state management, modules, and deploying your first cloud resources step by step.
- DevOps Terraform vs Pulumi: Infrastructure as Code Compared
Compare Terraform and Pulumi for infrastructure as code. Understand the tradeoffs between HCL and general-purpose languages for managing cloud resources.
- DevOps Ansible Playbooks for Configuration Management
A hands-on guide to writing Ansible playbooks that configure servers, deploy apps, and enforce desired state across your fleet.