Skip to content
SumGuy's Ramblings
Go back

Terraform vs Pulumi: Infrastructure as Code Without the YAML Nightmares

The State File That Became Sentient

Deep in your team’s S3 bucket there is a file called terraform.tfstate. It is JSON. It contains the current state of all your infrastructure as Terraform understands it. It is the source of truth, the oracle, the thing you must never edit by hand and never, ever delete.

Someone always edits it by hand eventually. Someone always deletes it. And for about forty-five terrifying minutes, nobody knows if running terraform apply will recreate everything, destroy everything, or both simultaneously.

This is not a knock on Terraform specifically — Infrastructure as Code as a concept requires tracking state somewhere. But the way Terraform externalizes that state file, and the rituals that accumulate around keeping it intact, is one of the defining experiences of modern DevOps. Your Terraform state file is either fine or it is the source of a major incident. There is rarely a middle ground.

Let’s talk about both major IaC options, what they do differently, and which one is right for your situation.

What Is Infrastructure as Code and Why Does It Matter?

Before comparing tools: Infrastructure as Code (IaC) means describing your infrastructure in files that can be version-controlled, reviewed, tested, and applied automatically — instead of clicking around in a cloud console or running manual scripts.

The benefits compound over time:

The alternative — imperative scripts or manual console clicks — starts to break down when you have more than a handful of resources or more than one person making changes. IaC is the professional answer.

Terraform: The Incumbent

Terraform was created by HashiCorp in 2014 and became the dominant IaC tool partly through timing, partly through good design decisions, and partly because it had an enormous provider ecosystem before any competitor caught up.

How Terraform Works

You write HCL (HashiCorp Configuration Language) describing desired state:

# main.tf

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }

  backend "s3" {
    bucket = "my-terraform-state"
    key    = "prod/terraform.tfstate"
    region = "us-east-1"
  }
}

provider "aws" {
  region = "us-east-1"
}

resource "aws_vpc" "main" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_hostnames = true

  tags = {
    Name        = "main-vpc"
    Environment = "production"
  }
}

resource "aws_subnet" "public" {
  count             = 2
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.${count.index}.0/24"
  availability_zone = data.aws_availability_zones.available.names[count.index]

  tags = {
    Name = "public-subnet-${count.index}"
  }
}

resource "aws_instance" "web" {
  ami           = data.aws_ami.ubuntu.id
  instance_type = "t3.micro"
  subnet_id     = aws_subnet.public[0].id

  tags = {
    Name = "web-server"
  }
}

The workflow:

terraform init     # Download providers and configure backend
terraform plan     # Show what would change (dry run)
terraform apply    # Apply the changes
terraform destroy  # Tear it all down

plan is Terraform’s killer feature — you see exactly what will be created, modified, or destroyed before anything happens. The green +, yellow ~, and red - symbols become familiar friends.

Terraform State Management

State is stored wherever your backend block points. Local (default), S3, GCS, Terraform Cloud, and others.

# See current state
terraform state list

# Show specific resource
terraform state show aws_instance.web

# Remove resource from state without destroying it
terraform state rm aws_instance.web

# Import existing resource into state
terraform import aws_instance.web i-1234567890abcdef0

# Move resource in state (renaming)
terraform state mv aws_instance.web aws_instance.frontend

State locking is critical when multiple people or pipelines run Terraform simultaneously. The S3 backend with DynamoDB locking is the standard production setup:

backend "s3" {
  bucket         = "my-terraform-state"
  key            = "prod/terraform.tfstate"
  region         = "us-east-1"
  dynamodb_table = "terraform-locks"
}

Terraform Workspaces and the Module System

Workspaces let you manage multiple environments from the same code:

terraform workspace new staging
terraform workspace select staging
terraform apply

Modules let you package reusable infrastructure:

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "5.0.0"

  name = "my-vpc"
  cidr = "10.0.0.0/16"
  azs  = ["us-east-1a", "us-east-1b"]
}

The Terraform Registry has thousands of community modules. This is part of why Terraform dominates: there’s almost certainly a module for what you need.

OpenTofu: The HashiCorp Fork

In 2023, HashiCorp changed Terraform’s license from Mozilla Public License to BUSL (Business Source License), which restricts commercial use. The community forked it as OpenTofu, which remains open-source under MPL.

# OpenTofu is a drop-in replacement
tofu init
tofu plan
tofu apply

OpenTofu is API-compatible with Terraform. If you’re using Terraform for non-commercial purposes, the distinction may not matter. If you’re building a product or service on top, OpenTofu is the safe choice.

Pulumi: Real Code for Infrastructure

Pulumi (founded 2017) takes a fundamentally different approach: write infrastructure in actual programming languages. TypeScript, Python, Go, C#, Java. Real loops, real functions, real type checking, real tests.

How Pulumi Works

Same infrastructure, but in TypeScript:

// index.ts
import * as aws from "@pulumi/aws";
import * as pulumi from "@pulumi/pulumi";

const config = new pulumi.Config();
const environment = config.require("environment");

// Create VPC
const vpc = new aws.ec2.Vpc("main", {
    cidrBlock: "10.0.0.0/16",
    enableDnsHostnames: true,
    tags: {
        Name: `main-vpc`,
        Environment: environment,
    },
});

// Create subnets — actual loops!
const azs = await aws.getAvailabilityZones({ state: "available" });
const publicSubnets = azs.names.slice(0, 2).map((az, i) =>
    new aws.ec2.Subnet(`public-subnet-${i}`, {
        vpcId: vpc.id,
        cidrBlock: `10.0.${i}.0/24`,
        availabilityZone: az,
        tags: { Name: `public-subnet-${i}` },
    })
);

// Use functions for reusable patterns
function createWebServer(name: string, subnet: aws.ec2.Subnet) {
    return new aws.ec2.Instance(name, {
        ami: "ami-0c55b159cbfafe1f0",
        instanceType: "t3.micro",
        subnetId: subnet.id,
        tags: { Name: name },
    });
}

const webServer = createWebServer("web-server", publicSubnets[0]);

export const webServerPublicIp = webServer.publicIp;

The same workflow:

pulumi login              # Configure state backend
pulumi stack init prod    # Create a stack (like Terraform workspace)
pulumi preview            # Like terraform plan
pulumi up                 # Like terraform apply
pulumi destroy

Same Python:

import pulumi
import pulumi_aws as aws

config = pulumi.Config()
environment = config.require("environment")

vpc = aws.ec2.Vpc("main",
    cidr_block="10.0.0.0/16",
    enable_dns_hostnames=True,
    tags={"Name": "main-vpc", "Environment": environment}
)

# Actual list comprehension — not HCL's limited count/for_each
subnets = [
    aws.ec2.Subnet(f"subnet-{i}",
        vpc_id=vpc.id,
        cidr_block=f"10.0.{i}.0/24",
        tags={"Name": f"subnet-{i}"}
    )
    for i in range(3)
]

Pulumi State Management

Pulumi state can live in:

# Use S3 for state
pulumi login s3://my-pulumi-state-bucket

# Use local state
pulumi login --local

Testing Infrastructure with Pulumi

This is where Pulumi genuinely shines. Because it’s real code, you can unit test it:

// index.test.ts
import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";

pulumi.runtime.setMocks({
    newResource: (type, name, inputs) => ({ id: `${name}-id`, state: inputs }),
    call: (token, args) => args,
});

import { webServer } from "./index";

test("web server has correct instance type", async () => {
    const instanceType = await webServer.instanceType;
    expect(instanceType).toBe("t3.micro");
});

test("web server has required tags", async () => {
    const tags = await webServer.tags;
    expect(tags.Name).toBeTruthy();
});

Side-by-Side Comparison

Same infrastructure in both tools:

Terraform — create 3 S3 buckets with a loop:

locals {
  bucket_names = ["assets", "backups", "logs"]
}

resource "aws_s3_bucket" "buckets" {
  for_each = toset(local.bucket_names)
  bucket   = "${var.project}-${each.value}"
}

Pulumi TypeScript — same thing:

const bucketNames = ["assets", "backups", "logs"];
const buckets = bucketNames.map(name =>
    new aws.s3.Bucket(`${name}`, {
        bucket: `${project}-${name}`,
    })
);

The Terraform version is fine. The Pulumi version is just… normal code.

Feature Comparison Table

FeatureTerraformOpenTofuPulumi
LanguageHCLHCLTypeScript/Python/Go/C#
State managementExternal fileExternal filePulumi Cloud or external
Provider ecosystemMassiveSame as TFSame providers via bridging
TestingTerratest (external)SameNative unit tests
Loops/conditionalsLimited (count/for_each)SameFull language support
Type safetyMinimalSameFull (TypeScript)
Learning curveLow-MediumSameMedium (language + concepts)
IDE supportOKOKExcellent (TypeScript)
LicenseBUSL 1.1MPL 2.0Apache 2.0

When to Use Which

Use Terraform or OpenTofu when:

Use Pulumi when:

The real world: Many teams have both. Terraform or OpenTofu for stable foundational infrastructure (VPCs, IAM, databases), Pulumi for dynamic application infrastructure that needs code-level expressiveness.

The state file — Terraform’s or Pulumi’s — is precious. Back it up. Version it. Lock it. Treat it like your most critical production database, because in a meaningful sense, it is.


Share this post on:

Previous Post
LangChain vs LlamaIndex: When Your AI Needs to Talk to Your Data
Next Post
WireGuard vs OpenVPN in 2026: Speed, Simplicity, and Staying Connected