Pulumi vs Terraform: When TypeScript Beats HCL
A practical comparison of Pulumi and Terraform, exploring when programming languages provide advantages over HCL for infrastructure as code.
Terraform’s HCL is the lingua franca of infrastructure as code, but Pulumi’s approach—real programming languages—solves problems that HCL can’t. This guide covers when to use each and how to make the switch.
The Core Difference
Terraform uses HCL (HashiCorp Configuration Language), a domain-specific language designed for infrastructure:
# Terraform
resource "aws_s3_bucket" "data" {
bucket = "my-data-bucket"
}
resource "aws_s3_bucket" "logs" {
bucket = "my-logs-bucket"
}
Pulumi uses general-purpose programming languages:
// Pulumi (TypeScript)
import * as aws from "@pulumi/aws";
const dataBucket = new aws.s3.Bucket("data", {
bucket: "my-data-bucket",
});
const logsBucket = new aws.s3.Bucket("logs", {
bucket: "my-logs-bucket",
});
At first glance, they look similar. The difference becomes clear when complexity increases.
When Terraform Excels
Simple, Declarative Infrastructure
For straightforward infrastructure, HCL is clean and readable:
# VPC with subnets
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
}
resource "aws_subnet" "public" {
count = 3
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(aws_vpc.main.cidr_block, 8, count.index)
availability_zone = data.aws_availability_zones.available.names[count.index]
}
Massive Ecosystem
Terraform has providers for everything:
- 3,000+ providers in the registry
- Battle-tested modules
- Every cloud, every SaaS
Team Familiarity
Most DevOps engineers know HCL. That’s a real advantage.
When Pulumi Excels
Complex Logic and Conditionals
HCL’s conditional logic is awkward:
# Terraform - conditional resources
resource "aws_instance" "server" {
count = var.create_instance ? 1 : 0
# ...
}
# Accessing conditionally created resources is painful
output "instance_ip" {
value = var.create_instance ? aws_instance.server[0].public_ip : null
}
Pulumi handles complexity naturally:
// Pulumi - natural conditionals
if (config.createInstance) {
const server = new aws.ec2.Instance("server", {
// ...
});
export const instanceIp = server.publicIp;
}
Dynamic Resource Generation
Creating resources based on runtime data:
// Pulumi - generate resources from API data
import * as aws from "@pulumi/aws";
import * as pulumi from "@pulumi/pulumi";
interface TeamConfig {
name: string;
environments: string[];
bucketRetentionDays: number;
}
// Load team configs from external source
const teams: TeamConfig[] = await loadTeamConfigs();
for (const team of teams) {
for (const env of team.environments) {
new aws.s3.Bucket(`${team.name}-${env}-data`, {
bucket: `${team.name}-${env}-data-bucket`,
lifecycleRules: [{
enabled: true,
expiration: { days: team.bucketRetentionDays },
}],
tags: {
Team: team.name,
Environment: env,
},
});
}
}
The Terraform equivalent requires complex for_each with flattening:
# Terraform - same logic is more verbose
locals {
team_env_pairs = flatten([
for team in var.teams : [
for env in team.environments : {
team_name = team.name
env = env
retention = team.bucket_retention_days
}
]
])
}
resource "aws_s3_bucket" "team_buckets" {
for_each = { for pair in local.team_env_pairs : "${pair.team_name}-${pair.env}" => pair }
bucket = "${each.value.team_name}-${each.value.env}-data-bucket"
lifecycle_rule {
enabled = true
expiration {
days = each.value.retention
}
}
tags = {
Team = each.value.team_name
Environment = each.value.env
}
}
Sharing Code Through Packages
Pulumi components are just classes:
// lib/secure-bucket.ts
import * as aws from "@pulumi/aws";
import * as pulumi from "@pulumi/pulumi";
export interface SecureBucketArgs {
name: string;
enableVersioning?: boolean;
retentionDays?: number;
}
export class SecureBucket extends pulumi.ComponentResource {
public readonly bucket: aws.s3.Bucket;
public readonly bucketPolicy: aws.s3.BucketPolicy;
constructor(name: string, args: SecureBucketArgs, opts?: pulumi.ComponentResourceOptions) {
super("custom:storage:SecureBucket", name, args, opts);
this.bucket = new aws.s3.Bucket(`${name}-bucket`, {
bucket: args.name,
versioning: { enabled: args.enableVersioning ?? true },
serverSideEncryptionConfiguration: {
rule: {
applyServerSideEncryptionByDefault: {
sseAlgorithm: "aws:kms",
},
},
},
lifecycleRules: args.retentionDays ? [{
enabled: true,
noncurrentVersionExpiration: { days: args.retentionDays },
}] : [],
}, { parent: this });
// Block all public access
new aws.s3.BucketPublicAccessBlock(`${name}-public-access-block`, {
bucket: this.bucket.id,
blockPublicAcls: true,
blockPublicPolicy: true,
ignorePublicAcls: true,
restrictPublicBuckets: true,
}, { parent: this });
// Enforce SSL
this.bucketPolicy = new aws.s3.BucketPolicy(`${name}-ssl-policy`, {
bucket: this.bucket.id,
policy: this.bucket.arn.apply(arn => JSON.stringify({
Version: "2012-10-17",
Statement: [{
Effect: "Deny",
Principal: "*",
Action: "s3:*",
Resource: [arn, `${arn}/*`],
Condition: {
Bool: { "aws:SecureTransport": "false" },
},
}],
})),
}, { parent: this });
this.registerOutputs({
bucketName: this.bucket.bucket,
bucketArn: this.bucket.arn,
});
}
}
// main.ts - using the component
import { SecureBucket } from "./lib/secure-bucket";
const dataBucket = new SecureBucket("data", {
name: "my-secure-data-bucket",
enableVersioning: true,
retentionDays: 90,
});
export const bucketName = dataBucket.bucket.bucket;
Testing Infrastructure
Real programming languages have real testing frameworks:
// __tests__/secure-bucket.test.ts
import * as pulumi from "@pulumi/pulumi";
import { SecureBucket } from "../lib/secure-bucket";
// Mock Pulumi runtime
pulumi.runtime.setMocks({
newResource: (args) => ({
id: `${args.name}-id`,
state: args.inputs,
}),
call: () => ({}),
});
describe("SecureBucket", () => {
let bucket: SecureBucket;
beforeAll(async () => {
bucket = new SecureBucket("test", {
name: "test-bucket",
enableVersioning: true,
});
});
test("enables versioning by default", async () => {
const versioning = await new Promise<any>((resolve) =>
bucket.bucket.versioning.apply(v => resolve(v))
);
expect(versioning.enabled).toBe(true);
});
test("enables server-side encryption", async () => {
const encryption = await new Promise<any>((resolve) =>
bucket.bucket.serverSideEncryptionConfiguration.apply(c => resolve(c))
);
expect(encryption.rule.applyServerSideEncryptionByDefault.sseAlgorithm).toBe("aws:kms");
});
});
Async Operations and External Data
// Fetch data from APIs during deployment
import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";
import fetch from "node-fetch";
async function getLatestAmiId(region: string): Promise<string> {
const response = await fetch(`https://api.example.com/amis/${region}/latest`);
const data = await response.json();
return data.amiId;
}
const config = new pulumi.Config();
const region = aws.config.region || "us-east-1";
// Use async data in resources
pulumi.output(getLatestAmiId(region)).apply(amiId => {
new aws.ec2.Instance("server", {
ami: amiId,
instanceType: "t3.micro",
});
});
Language Comparison
Pulumi supports multiple languages:
# Pulumi - Python
import pulumi
import pulumi_aws as aws
bucket = aws.s3.Bucket("my-bucket",
bucket="my-unique-bucket-name",
versioning=aws.s3.BucketVersioningArgs(
enabled=True,
),
)
pulumi.export("bucket_name", bucket.bucket)
// Pulumi - Go
package main
import (
"github.com/pulumi/pulumi-aws/sdk/v6/go/aws/s3"
"github.com/pulumi/pulumi/sdk/v3/go/pulumi"
)
func main() {
pulumi.Run(func(ctx *pulumi.Context) error {
bucket, err := s3.NewBucket(ctx, "my-bucket", &s3.BucketArgs{
Bucket: pulumi.String("my-unique-bucket-name"),
Versioning: &s3.BucketVersioningArgs{
Enabled: pulumi.Bool(true),
},
})
if err != nil {
return err
}
ctx.Export("bucketName", bucket.Bucket)
return nil
})
}
// Pulumi - C#
using Pulumi;
using Pulumi.Aws.S3;
return await Deployment.RunAsync(() =>
{
var bucket = new Bucket("my-bucket", new BucketArgs
{
BucketName = "my-unique-bucket-name",
Versioning = new BucketVersioningArgs
{
Enabled = true,
},
});
return new Dictionary<string, object?>
{
["bucketName"] = bucket.BucketName,
};
});
State Management
Both tools manage state similarly:
Pulumi
# Pulumi.yaml
name: my-project
runtime: nodejs
# Pulumi.<stack>.yaml
config:
aws:region: us-east-1
# State backends
pulumi login # Pulumi Cloud (default)
pulumi login s3://my-bucket # S3
pulumi login file://~/.pulumi # Local
Terraform
terraform {
backend "s3" {
bucket = "terraform-state"
key = "prod/terraform.tfstate"
region = "us-east-1"
}
}
Migration Path
Terraform to Pulumi
Pulumi can import existing Terraform state:
# Convert Terraform HCL to Pulumi
pulumi convert --from terraform --out pulumi-project
# Import existing state
cd pulumi-project
pulumi import --from terraform ../terraform.tfstate
Using Both Together
Pulumi can reference Terraform state:
import * as pulumi from "@pulumi/pulumi";
import * as terraform from "@pulumi/terraform";
import * as aws from "@pulumi/aws";
// Reference Terraform remote state
const networkState = new terraform.state.RemoteStateReference("network", {
backendType: "s3",
bucket: "terraform-state",
key: "network/terraform.tfstate",
region: "us-east-1",
});
// Use outputs from Terraform
const vpcId = networkState.getOutput("vpc_id");
const subnetIds = networkState.getOutput("private_subnet_ids");
// Create Pulumi resources using Terraform outputs
new aws.ec2.Instance("server", {
ami: "ami-12345678",
instanceType: "t3.micro",
subnetId: subnetIds.apply(ids => ids[0]),
vpcSecurityGroupIds: [
networkState.getOutput("default_security_group_id"),
],
});
Cost Comparison
| Aspect | Terraform | Pulumi |
|---|---|---|
| Open Source | Yes (BSL for enterprise) | Yes (Apache 2.0) |
| State Management | Free (self-hosted) | Free tier / $$ for teams |
| Enterprise Features | Terraform Cloud $$ | Pulumi Cloud $$ |
| Learning Curve | HCL (new language) | Your existing language |
Decision Framework
Choose Terraform when:
- Team already knows HCL
- Simple, static infrastructure
- Maximum provider ecosystem coverage
- HashiCorp enterprise support needed
Choose Pulumi when:
- Complex conditional logic required
- Want to use existing language skills
- Need proper unit testing
- Building reusable infrastructure libraries
- Integrating with application code
Choose both when:
- Migrating gradually
- Different teams have different preferences
- Some infrastructure is simple (Terraform), some complex (Pulumi)
Key Takeaways
- HCL is great for simple infrastructure—don’t overcomplicate what doesn’t need it
- Real languages shine with complexity—loops, conditionals, and abstractions
- Testing matters—Pulumi enables real unit tests for infrastructure
- Ecosystem matters—Terraform has more providers (but Pulumi can use them via Terraform bridge)
- Team skills matter—use what your team knows
- They’re not mutually exclusive—Pulumi can reference Terraform state
The best IaC tool is the one your team will use correctly. For complex, application-adjacent infrastructure, Pulumi’s programming language approach provides real advantages. For standard cloud infrastructure, Terraform’s simplicity and ecosystem are hard to beat.