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

AspectTerraformPulumi
Open SourceYes (BSL for enterprise)Yes (Apache 2.0)
State ManagementFree (self-hosted)Free tier / $$ for teams
Enterprise FeaturesTerraform Cloud $$Pulumi Cloud $$
Learning CurveHCL (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

  1. HCL is great for simple infrastructure—don’t overcomplicate what doesn’t need it
  2. Real languages shine with complexity—loops, conditionals, and abstractions
  3. Testing matters—Pulumi enables real unit tests for infrastructure
  4. Ecosystem matters—Terraform has more providers (but Pulumi can use them via Terraform bridge)
  5. Team skills matter—use what your team knows
  6. 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.