Pulumi vs Terraform: Choosing the Right Infrastructure as Code Tool

Bits Lovers
Written by Bits Lovers on
Pulumi vs Terraform: Choosing the Right Infrastructure as Code Tool

Terraform and Pulumi solve the same problem — declaring cloud infrastructure and tracking its state — but with fundamentally different approaches to how you express that declaration. Terraform uses HCL, a domain-specific language designed specifically for infrastructure. Pulumi uses general-purpose programming languages: TypeScript, Python, Go, C#, Java. That difference has real consequences for what’s easy, what’s painful, and who should use each tool.

This isn’t a case where one tool is objectively better. Terraform’s HCL forces a declarative style that’s readable by people who don’t code professionally. Pulumi’s Python or TypeScript gives you real loops, conditionals, functions, and unit tests, which matters enormously when your infrastructure has complex conditional logic. The choice depends on your team, your infrastructure complexity, and what you’re willing to trade.

The Core Difference in Practice

The same S3 bucket with versioning in both tools:

# Terraform (HCL)
resource "aws_s3_bucket" "data_lake" {
  bucket = "my-data-lake-${var.environment}"
  
  tags = {
    Environment = var.environment
    Team        = "data"
  }
}

resource "aws_s3_bucket_versioning" "data_lake" {
  bucket = aws_s3_bucket.data_lake.id
  versioning_configuration {
    status = "Enabled"
  }
}
# Pulumi (Python)
import pulumi
import pulumi_aws as aws

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

bucket = aws.s3.BucketV2(
    f"data-lake-{environment}",
    bucket=f"my-data-lake-{environment}",
    tags={
        "Environment": environment,
        "Team": "data",
    }
)

versioning = aws.s3.BucketVersioningV2(
    "data-lake-versioning",
    bucket=bucket.id,
    versioning_configuration=aws.s3.BucketVersioningV2VersioningConfigurationArgs(
        status="Enabled"
    )
)

pulumi.export("bucket_name", bucket.bucket)

For this simple case, Terraform is less verbose. HCL’s declarative syntax is concise when infrastructure is straightforward. The verbosity gap inverts when logic gets complex.

Where Pulumi Wins: Complex Logic

Creating 5 S3 buckets in Terraform requires 5 resource blocks (or a for_each loop that reads like config). In Python it’s a list comprehension:

# Pulumi: create buckets for each team
teams = ["data", "ml", "platform", "security", "frontend"]

buckets = {}
for team in teams:
    bucket = aws.s3.BucketV2(
        f"team-bucket-{team}",
        bucket=f"myorg-{environment}-{team}",
        tags={"Team": team, "Environment": environment}
    )
    
    # Each team gets its own lifecycle policy
    aws.s3.BucketLifecycleConfigurationV2(
        f"team-lifecycle-{team}",
        bucket=bucket.id,
        rules=[aws.s3.BucketLifecycleConfigurationV2RuleArgs(
            id="archive",
            status="Enabled",
            transitions=[aws.s3.BucketLifecycleConfigurationV2RuleTransitionArgs(
                days=90,
                storage_class="GLACIER"
            )]
        )]
    )
    buckets[team] = bucket

# Export all bucket names
pulumi.export("buckets", {team: b.bucket for team, b in buckets.items()})
# Terraform equivalent requires for_each or count
locals {
  teams = ["data", "ml", "platform", "security", "frontend"]
}

resource "aws_s3_bucket" "team_buckets" {
  for_each = toset(local.teams)
  bucket   = "myorg-${var.environment}-${each.key}"
  tags = {
    Team        = each.key
    Environment = var.environment
  }
}

resource "aws_s3_bucket_lifecycle_configuration" "team_lifecycles" {
  for_each = toset(local.teams)
  bucket   = aws_s3_bucket.team_buckets[each.key].id
  
  rule {
    id     = "archive"
    status = "Enabled"
    transition {
      days          = 90
      storage_class = "GLACIER"
    }
  }
}

Both work. The Terraform version is actually readable. But now add a conditional: only the data team bucket gets a specific bucket policy; only ml and platform get replication; security gets a special encryption configuration. In Terraform, this requires count = condition ? 1 : 0 tricks on resources, or separate resource blocks per exception. In Python, it’s if team == "data":.

Where Terraform Wins: Ecosystem and Stability

Terraform has been the standard since 2014. The provider ecosystem is massive, well-documented, and battle-tested. The HCL syntax has one way to do most things, which means your module written 3 years ago still works today and looks familiar to any new team member.

Pulumi’s ecosystem is good but catches up to new AWS features slightly later. When AWS launches a new service, the Terraform AWS provider typically has it within weeks. Pulumi’s AWS provider (which wraps the Terraform provider via the terraform-bridge) follows shortly after.

Terraform modules — reusable infrastructure components — are straightforward:

# modules/s3-data-lake/main.tf
variable "environment" {}
variable "retention_days" { default = 90 }

resource "aws_s3_bucket" "this" {
  bucket = "data-lake-${var.environment}"
}

resource "aws_s3_bucket_intelligent_tiering_configuration" "this" {
  bucket = aws_s3_bucket.this.bucket
  name   = "auto-tiering"
  
  tiering {
    access_tier = "DEEP_ARCHIVE_ACCESS"
    days        = var.retention_days
  }
}

output "bucket_arn" {
  value = aws_s3_bucket.this.arn
}
# Consuming the module
module "prod_data_lake" {
  source         = "./modules/s3-data-lake"
  environment    = "production"
  retention_days = 180
}

Pulumi has ComponentResources (the equivalent of Terraform modules), but the syntax is more verbose and the concept is less universally understood in the community. For teams sharing infrastructure components, Terraform modules have more documentation, more examples, and more community support.

State Management

Both tools maintain state — a record of what resources exist and their IDs. Without state, a second apply would try to create everything again.

Terraform state defaults to a local file (terraform.tfstate). For teams, you need a backend:

# terraform/backend.tf
terraform {
  backend "s3" {
    bucket         = "my-terraform-state"
    key            = "production/api/terraform.tfstate"
    region         = "us-east-1"
    encrypt        = true
    dynamodb_table = "terraform-state-locks"
  }
}
# Create the state infrastructure first
aws s3 mb s3://my-terraform-state
aws dynamodb create-table \
  --table-name terraform-state-locks \
  --attribute-definitions AttributeName=LockID,AttributeType=S \
  --key-schema AttributeName=LockID,KeyType=HASH \
  --billing-mode PAY_PER_REQUEST

Pulumi state defaults to the Pulumi Cloud (their SaaS). Self-hosted backends use S3:

# Use S3 as Pulumi backend
pulumi login s3://my-pulumi-state-bucket

# Create a new stack
pulumi stack init production

# Preview changes
pulumi preview

# Deploy
pulumi up

# Destroy
pulumi destroy

Pulumi Cloud’s free tier covers 1 user with unlimited stacks. The Team tier starts at $50/month. Terraform Cloud’s free tier covers 500 managed resources per month before requiring a paid plan. For self-hosted state (S3 + DynamoDB for Terraform, S3 for Pulumi), both are free.

Testing Infrastructure Code

This is where Pulumi has a genuine advantage. You can write real unit tests against Pulumi programs using standard testing frameworks:

# test_infrastructure.py
import unittest
import pulumi
from unittest.mock import MagicMock

class MyMocks(pulumi.runtime.Mocks):
    def new_resource(self, args):
        return [args.name + '_id', args.inputs]
    
    def call(self, args):
        return {}

pulumi.runtime.set_mocks(MyMocks())

import infra  # Your Pulumi program

class TestInfrastructure(unittest.TestCase):
    @pulumi.runtime.test
    def test_bucket_has_versioning(self):
        def check_bucket(args):
            bucket_name, versioning_status = args
            self.assertEqual(versioning_status, "Enabled")
        
        return pulumi.Output.all(
            infra.bucket.bucket,
            infra.versioning.versioning_configuration.status
        ).apply(check_bucket)
    
    @pulumi.runtime.test
    def test_environment_tag_present(self):
        def check_tags(tags):
            self.assertIn("Environment", tags)
        
        return infra.bucket.tags.apply(check_tags)

if __name__ == "__main__":
    unittest.main()

Terraform has terraform test (added in 1.6) but it spins up real infrastructure and tears it down — integration testing, not unit testing. For compliance checks and policy-as-code on Terraform plans, Sentinel (Terraform Cloud) or Open Policy Agent / conftest are the common approaches.

Migration Path

If you’re on Terraform and considering Pulumi, pulumi convert imports an existing Terraform configuration:

# Convert Terraform HCL to Pulumi TypeScript
pulumi convert \
  --from terraform \
  --language typescript \
  --out pulumi-output/

# The existing state can be imported
cd pulumi-output
pulumi stack import --file terraform.tfstate

The converted code often needs cleanup — pulumi convert is a starting point, not a finished migration. Complex Terraform modules with count, for_each, and conditional resources don’t translate cleanly to Pulumi’s imperative model automatically.

The Decision

Choose Terraform when: your team has existing HCL expertise, you need the largest module ecosystem, your infrastructure is relatively static and declarative in nature, you don’t need complex conditional logic, or your operations team (not just developers) manages infrastructure.

Choose Pulumi when: you’re a software engineering team comfortable with Python or TypeScript, your infrastructure has significant conditional logic or dynamic generation, you want to unit-test your infrastructure code, or you’re building internal developer platforms where infrastructure components are libraries consumed by other programs.

The GitHub Actions with Terraform guide covers CI/CD for Terraform workflows. For existing Terraform users adding AWS-specific features, the AWS CDK guide is another alternative — CDK generates CloudFormation rather than using a separate state backend, which avoids state management entirely at the cost of CloudFormation’s verbosity.

Bits Lovers

Bits Lovers

Professional writer and blogger. Focus on Cloud Computing.

Comments

comments powered by Disqus