Pulumi: IaC with Real Languages
Terraform defines infrastructure in HCL — a purpose-built configuration language. Pulumi takes a different approach: write infrastructure in TypeScript, Python, Go, Java, or C# using the full power of a general-purpose programming language. The same loops, conditionals, functions, classes, and package managers you use for application code apply directly to infrastructure.
What Is Pulumi?
Pulumi is an infrastructure-as-code framework with a runtime engine and a CLI. When you run pulumi up, Pulumi:
- Executes your program (TypeScript/Python/Go) to build an in-memory resource graph
- Compares the graph against the last deployed state
- Calls cloud provider APIs to converge real infrastructure to the desired state
The cloud-side mechanics are similar to Terraform — desired state, state file, provider plugins — but the authoring experience is a programming language rather than a DSL.
Pulumi vs Terraform
| Terraform / OpenTofu | Pulumi | |
|---|---|---|
| Language | HCL (domain-specific) | TypeScript, Python, Go, C#, Java, YAML |
| Logic (loops, conditions) | count, for_each, ternary | Native language constructs |
| Abstraction | Modules | Classes, functions, npm/pip packages |
| Testing | terraform test framework | Standard test frameworks (Jest, pytest) |
| Provider ecosystem | registry.terraform.io (large) | pulumi.com/registry (large, wraps Terraform providers) |
| State | JSON file | JSON file (compatible backends) |
| Managed cloud | HCP Terraform | Pulumi Cloud |
| Learning curve | Learn HCL | Know a supported language already |
Neither is universally better. Terraform's HCL is explicit and readable for infrastructure reviewers who aren't developers. Pulumi's general-purpose languages enable complex abstractions and integrate naturally into software engineering workflows.
Setup & First Program
# Install Pulumi CLI
curl -fsSL https://get.pulumi.com | sh
# Create a new TypeScript project
mkdir my-infra && cd my-infra
pulumi new aws-typescript
# This creates:
# index.ts — your infrastructure program
# package.json — Node.js dependencies
# Pulumi.yaml — project config
# Pulumi.dev.yaml — stack config for 'dev'
Deploy:
pulumi up # preview + deploy
pulumi preview # preview only (like terraform plan)
pulumi destroy # tear down all resources
Core Concepts
Resources
Each cloud resource is an object. Construct it to declare it:
import * as aws from "@pulumi/aws";
const bucket = new aws.s3.BucketV2("my-bucket", {
tags: { Environment: "production" },
});
Outputs
Resource properties that aren't known until after deployment are wrapped in Output<T> — Pulumi's equivalent of Terraform's unknown values. Use .apply() to transform them:
const bucketName = bucket.bucket; // Output
const url = bucket.bucket.apply(name =>
`https://${name}.s3.amazonaws.com`
);
export const bucketUrl = url; // export as stack output
Stack References
Read outputs from another stack (equivalent to terraform_remote_state):
const networkStack = new pulumi.StackReference("myorg/networking/prod");
const vpcId = networkStack.getOutput("vpcId");
TypeScript Example
A complete program that creates an S3 bucket with versioning, a CloudFront distribution, and exports the distribution URL:
import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";
const config = new pulumi.Config();
const environment = config.require("environment");
// S3 bucket
const bucket = new aws.s3.BucketV2("site-bucket", {
tags: {
Environment: environment,
ManagedBy: "pulumi",
},
});
// Enable versioning
new aws.s3.BucketVersioningV2("bucket-versioning", {
bucket: bucket.bucket,
versioningConfiguration: { status: "Enabled" },
});
// CloudFront distribution in front of S3
const distribution = new aws.cloudfront.Distribution("cdn", {
origins: [{
domainName: bucket.bucketRegionalDomainName,
originId: bucket.bucket,
s3OriginConfig: {
originAccessIdentity: "",
},
}],
enabled: true,
defaultCacheBehavior: {
targetOriginId: bucket.bucket,
viewerProtocolPolicy: "redirect-to-https",
allowedMethods: ["GET", "HEAD"],
cachedMethods: ["GET", "HEAD"],
forwardedValues: {
queryString: false,
cookies: { forward: "none" },
},
},
restrictions: {
geoRestriction: { restrictionType: "none" },
},
viewerCertificate: {
cloudfrontDefaultCertificate: true,
},
});
export const distributionUrl = pulumi.interpolate`https://${distribution.domainName}`;
export const bucketName = bucket.bucket;
This uses real TypeScript: pulumi.interpolate (a tagged template literal that handles Output<T> values), type checking, and IDE autocomplete — none of which exist in HCL.
Python Example
import pulumi
import pulumi_aws as aws
config = pulumi.Config()
environment = config.require("environment")
# Create VPC
vpc = aws.ec2.Vpc("main-vpc",
cidr_block="10.0.0.0/16",
enable_dns_hostnames=True,
tags={"Environment": environment, "ManagedBy": "pulumi"},
)
# Create subnets in multiple AZs using a loop
azs = ["us-east-1a", "us-east-1b", "us-east-1c"]
subnets = []
for i, az in enumerate(azs):
subnet = aws.ec2.Subnet(f"public-subnet-{i}",
vpc_id=vpc.id,
cidr_block=f"10.0.{i}.0/24",
availability_zone=az,
map_public_ip_on_launch=True,
tags={"Name": f"public-{az}"},
)
subnets.append(subnet)
pulumi.export("vpc_id", vpc.id)
pulumi.export("subnet_ids", [s.id for s in subnets])
The Python loop over AZs is idiomatic Python — no equivalent of for_each or count needed. The type system and pip ecosystem are available for validation, testing, and sharing abstractions.
State & Backends
Pulumi state works like Terraform state — a JSON file mapping your program's resources to real cloud objects. Backends:
| Backend | Use case |
|---|---|
| Pulumi Cloud (default) | Managed, free tier, concurrency control, secrets encryption |
| S3 / GCS / Azure Blob | Self-managed, no Pulumi account required |
| Local filesystem | Single-developer, no sharing |
Configure a self-managed S3 backend:
pulumi login s3://my-pulumi-state-bucket
Pulumi Cloud has a free tier covering unlimited stacks for individuals, with team features (RBAC, audit logs, secrets) on paid plans.
Stacks & Config
A stack is an isolated deployment of a Pulumi program — equivalent to a Terraform workspace. Each stack has its own state and config:
# Create stacks for each environment
pulumi stack init dev
pulumi stack init staging
pulumi stack init prod
# Set per-stack config
pulumi config set environment dev --stack dev
pulumi config set environment prod --stack prod
# Set secrets (encrypted in state)
pulumi config set --secret db_password "s3cr3t" --stack prod
Stack config is read in your program with pulumi.Config():
const config = new pulumi.Config();
const dbPassword = config.requireSecret("db_password"); // Output, value encrypted
const environment = config.require("environment"); // string, plaintext
Config values are stored in Pulumi.<stackname>.yaml files which are safe to commit — secrets are encrypted before being stored.
Key Takeaways
- Pulumi uses general-purpose programming languages (TypeScript, Python, Go, C#) instead of HCL. The same loops, classes, and package managers you use for app code apply to infrastructure.
- The deploy model is similar to Terraform: desired state, state file, provider plugins.
pulumi up= plan + apply;pulumi preview= plan only. - Outputs are typed
Output<T>values — use.apply()orpulumi.interpolateto transform them without blocking. - Stacks are isolated deployments (like Terraform workspaces). Each stack has its own state and config. Secrets are encrypted in stack config files.
- Choose Pulumi when your team is already strong in a supported language, when you need complex abstractions or testing with standard tools, or when you want to share infra components via package registries (npm, pip). Choose Terraform when you want explicit, auditable HCL that non-developers can review, or when your team has existing Terraform expertise.