MinhVo

Minh Vo

rss feed

Slaying code & making it lit fr fr πŸ”₯ tagline

Hey there πŸ‘‹ I'm an AI Engineer with 7 years of experience building scalable web and mobile applications. Currently at Neurond AI (May 2025 β€” present), architecting an Enterprise AI Assistant Platform with multi-tenant RAG on pgvector, multi-provider LLM orchestration, and Azure-native infrastructure. Previously spent 5+ years at SNAPTEC (Sep 2019 β€” Apr 2025), leading SaaS themes, admin dashboards, and e-commerce platforms β€” earned the Hero of the Year award in 2021. I specialize in TypeScript, React, Next.js, and AI-Native engineering with Claude Code and Cursor.bio

Back to blogs

Infrastructure as Code: Pulumi vs Terraform in 2024

Compare Pulumi and Terraform for IaC: languages, state management, and provider ecosystems.

IaCPulumiTerraformDevOpsCloud

By MinhVo

Introduction

Infrastructure as Code (IaC) has become a cornerstone of modern cloud operations, enabling teams to define, provision, and manage infrastructure through machine-readable configuration files. Two tools dominate the IaC landscape: Terraform by HashiCorp and Pulumi. While Terraform has been the industry standard since 2014, Pulumi has emerged as a compelling alternative that lets you write infrastructure code in general-purpose programming languages like TypeScript, Python, Go, and C#.

The choice between Terraform and Pulumi isn't simply about syntaxβ€”it's about workflow, ecosystem, team skills, and organizational needs. Terraform uses HCL (HashiCorp Configuration Language), a declarative domain-specific language designed specifically for infrastructure. Pulumi, on the other hand, lets you use the same languages your application code uses, enabling loops, conditionals, abstractions, and testing with familiar tools.

In this comprehensive comparison, we'll examine both tools across critical dimensions: language support, state management, provider ecosystems, testing capabilities, secret management, and real-world production patterns. Whether you're evaluating IaC tools for a new project or considering migration, this guide provides the detailed analysis you need to make an informed decision.

Cloud infrastructure architecture

Understanding IaC: Core Concepts

Declarative vs Imperative IaC

The fundamental difference between Terraform and Pulumi lies in their approach to infrastructure definition. Terraform is purely declarativeβ€”you describe what you want, and Terraform figures out how to create it. Pulumi supports both declarative and imperative patterns through general-purpose languages.

# Terraform: Declarative HCL
resource "aws_instance" "web" {
  ami           = "ami-0c55b159cbfafe1f0"
  instance_type = "t3.micro"
  
  tags = {
    Name = "web-server"
  }
}
 
resource "aws_security_group" "web" {
  name = "web-sg"
  
  ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
}
// Pulumi: Imperative TypeScript
import * as aws from "@pulumi/aws";
 
const securityGroup = new aws.ec2.SecurityGroup("web-sg", {
  ingress: [{
    fromPort: 80,
    toPort: 80,
    protocol: "tcp",
    cidrBlocks: ["0.0.0.0/0"],
  }],
});
 
const instance = new aws.ec2.Instance("web", {
  ami: "ami-0c55b159cbfafe1f0",
  instanceType: "t3.micro",
  vpcSecurityGroupIds: [securityGroup.id],
  tags: { Name: "web-server" },
});

State Management

Both tools maintain state files that map your configuration to real-world resources. Terraform stores state in a .tfstate file (local or remote), while Pulumi stores state in a backend (Pulumi Cloud, S3, local filesystem, etc.).

Terraform's state management is more mature with well-established patterns for team collaboration, state locking, and remote backends. Pulumi's state management is built on similar concepts but offers additional features like built-in encryption and the Pulumi Cloud service.

Resource Graph and Dependencies

Both tools build a dependency graph to determine the order of operations. Terraform infers dependencies from resource references, while Pulumi does the same through promise resolution in programming languages.

# Terraform: implicit dependency
resource "aws_vpc" "main" {
  cidr_block = "10.0.0.0/16"
}
 
resource "aws_subnet" "public" {
  vpc_id     = aws_vpc.main.id  # Terraform knows subnet depends on VPC
  cidr_block = "10.0.1.0/24"
}
// Pulumi: implicit dependency through outputs
const vpc = new aws.ec2.Vpc("main", {
  cidrBlock: "10.0.0.0/16",
});
 
const subnet = new aws.ec2.Subnet("public", {
  vpcId: vpc.id,  // Pulumi resolves the dependency
  cidrBlock: "10.0.1.0/24",
});

IaC workflow diagram

Architecture and Design Patterns

Project Structure Comparison

Terraform Project Structure:

infrastructure/
β”œβ”€β”€ main.tf           # Main configuration
β”œβ”€β”€ variables.tf      # Input variables
β”œβ”€β”€ outputs.tf        # Output values
β”œβ”€β”€ providers.tf      # Provider configuration
β”œβ”€β”€ terraform.tfvars  # Variable values
β”œβ”€β”€ modules/
β”‚   β”œβ”€β”€ vpc/
β”‚   β”‚   β”œβ”€β”€ main.tf
β”‚   β”‚   β”œβ”€β”€ variables.tf
β”‚   β”‚   └── outputs.tf
β”‚   └── ec2/
β”‚       β”œβ”€β”€ main.tf
β”‚       β”œβ”€β”€ variables.tf
β”‚       └── outputs.tf
└── environments/
    β”œβ”€β”€ dev.tfvars
    β”œβ”€β”€ staging.tfvars
    └── prod.tfvars

Pulumi Project Structure:

infrastructure/
β”œβ”€β”€ Pulumi.yaml          # Project configuration
β”œβ”€β”€ Pulumi.dev.yaml      # Dev stack config
β”œβ”€β”€ Pulumi.prod.yaml     # Prod stack config
β”œβ”€β”€ index.ts             # Main entry point
β”œβ”€β”€ src/
β”‚   β”œβ”€β”€ vpc.ts          # VPC component
β”‚   β”œβ”€β”€ ec2.ts          # EC2 component
β”‚   β”œβ”€β”€ rds.ts          # RDS component
β”‚   └── config.ts       # Configuration
β”œβ”€β”€ tests/
β”‚   └── infrastructure.test.ts
└── package.json

Module and Component Patterns

Terraform modules are reusable packages of Terraform configuration. Pulumi achieves similar reuse through programming language constructs like functions, classes, and component resources.

# Terraform module usage
module "vpc" {
  source = "./modules/vpc"
  
  cidr_block      = "10.0.0.0/16"
  environment     = var.environment
  availability_zones = ["us-east-1a", "us-east-1b"]
}
 
module "app_cluster" {
  source = "./modules/ecs-cluster"
  
  vpc_id          = module.vpc.vpc_id
  subnet_ids      = module.vpc.private_subnet_ids
  cluster_name    = "app-${var.environment}"
  desired_count   = var.environment == "prod" ? 3 : 1
}
// Pulumi component resource
import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";
 
interface VpcArgs {
  cidrBlock: string;
  environment: string;
  availabilityZones: string[];
}
 
class VpcComponent extends pulumi.ComponentResource {
  public readonly vpcId: pulumi.Output<string>;
  public readonly privateSubnetIds: pulumi.Output<string[]>;
  
  constructor(name: string, args: VpcArgs, opts?: pulumi.ComponentResourceOptions) {
    super("custom:network:Vpc", name, {}, opts);
    
    const vpc = new aws.ec2.Vpc(`${name}-vpc`, {
      cidrBlock: args.cidrBlock,
      tags: { Name: `${name}-vpc`, Environment: args.environment },
    }, { parent: this });
    
    this.vpcId = vpc.id;
    
    const subnets = args.availabilityZones.map((az, i) => 
      new aws.ec2.Subnet(`${name}-subnet-${i}`, {
        vpcId: vpc.id,
        cidrBlock: `10.0.${i}.0/24`,
        availabilityZone: az,
      }, { parent: this })
    );
    
    this.privateSubnetIds = pulumi.output(subnets.map(s => s.id));
  }
}
 
// Usage
const vpc = new VpcComponent("main", {
  cidrBlock: "10.0.0.0/16",
  environment: "production",
  availabilityZones: ["us-east-1a", "us-east-1b", "us-east-1c"],
});

Multi-Environment Strategy

Both tools support multi-environment deployments through different mechanisms. Terraform uses workspaces and variable files, while Pulumi uses stacks.

# Terraform workspace-based environments
# terraform workspace select prod
# terraform apply -var-file="environments/prod.tfvars"
 
variable "instance_count" {
  type = map(number)
  default = {
    dev     = 1
    staging = 2
    prod    = 5
  }
}
 
resource "aws_instance" "app" {
  count = var.instance_count[terraform.workspace]
  # ...
}
// Pulumi stack-based configuration
import * as pulumi from "@pulumi/pulumi";
 
const config = new pulumi.Config();
const environment = pulumi.getStack();
const instanceCount = config.requireNumber("instanceCount");
 
// Pulumi.dev.yaml: instanceCount: 1
// Pulumi.prod.yaml: instanceCount: 5
 
const instances = Array.from({ length: instanceCount }, (_, i) => 
  new aws.ec2.Instance(`app-${i}`, {
    ami: "ami-0c55b159cbfafe1f0",
    instanceType: environment === "prod" ? "t3.large" : "t3.micro",
  })
);

Infrastructure comparison

Step-by-Step Implementation

Deploying a Full-Stack Application

Let's deploy a complete application stack with both tools to illustrate the practical differences.

Terraform Implementation:

# providers.tf
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
  
  backend "s3" {
    bucket = "my-terraform-state"
    key    = "infrastructure/terraform.tfstate"
    region = "us-east-1"
  }
}
 
provider "aws" {
  region = var.aws_region
}
 
# variables.tf
variable "aws_region" {
  default = "us-east-1"
}
 
variable "environment" {
  type = string
}
 
variable "db_password" {
  type      = string
  sensitive = true
}
 
# main.tf
resource "aws_db_instance" "main" {
  identifier           = "app-db-${var.environment}"
  engine               = "postgres"
  engine_version       = "15"
  instance_class       = "db.t3.micro"
  allocated_storage    = 20
  db_name              = "appdb"
  username             = "admin"
  password             = var.db_password
  skip_final_snapshot  = var.environment != "prod"
  final_snapshot_identifier = var.environment == "prod" ? "app-db-final" : null
  
  tags = {
    Environment = var.environment
  }
}
 
resource "aws_ecs_cluster" "main" {
  name = "app-cluster-${var.environment}"
}
 
resource "aws_ecs_task_definition" "app" {
  family                   = "app"
  network_mode             = "awsvpc"
  requires_compatibilities = ["FARGATE"]
  cpu                      = "256"
  memory                   = "512"
  
  container_definitions = jsonencode([{
    name  = "app"
    image = "${aws_ecr_repository.app.repository_url}:latest"
    portMappings = [{ containerPort = 3000 }]
    environment = [
      { name = "DATABASE_URL", value = "postgresql://admin:${var.db_password}@${aws_db_instance.main.endpoint}/appdb" }
    ]
  }])
}
 
resource "aws_ecs_service" "app" {
  name            = "app-service"
  cluster         = aws_ecs_cluster.main.id
  task_definition = aws_ecs_task_definition.app.arn
  desired_count   = var.environment == "prod" ? 3 : 1
  launch_type     = "FARGATE"
  
  network_configuration {
    subnets          = module.vpc.private_subnet_ids
    security_groups  = [aws_security_group.app.id]
    assign_public_ip = false
  }
}

Pulumi Implementation:

// index.ts
import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";
import * as awsx from "@pulumi/awsx";
 
const config = new pulumi.Config();
const environment = pulumi.getStack();
const dbPassword = config.requireSecret("dbPassword");
 
// VPC
const vpc = new awsx.ec2.Vpc("app-vpc", {
  cidrBlock: "10.0.0.0/16",
  numberOfAvailabilityZones: 2,
  natGateways: { strategy: environment === "prod" ? "OnePerAz" : "None" },
});
 
// RDS Database
const dbSubnetGroup = new aws.rds.SubnetGroup("db-subnets", {
  subnetIds: vpc.privateSubnetIds,
});
 
const db = new aws.rds.Instance("app-db", {
  identifier: `app-db-${environment}`,
  engine: "postgres",
  engineVersion: "15",
  instanceClass: environment === "prod" ? "db.t3.medium" : "db.t3.micro",
  allocatedStorage: 20,
  dbName: "appdb",
  username: "admin",
  password: dbPassword,
  skipFinalSnapshot: environment !== "prod",
  dbSubnetGroupName: dbSubnetGroup.name,
  tags: { Environment: environment },
});
 
// ECR Repository
const repo = new aws.ecr.Repository("app-repo");
 
// ECS Cluster and Service
const cluster = new aws.ecs.Cluster("app-cluster", {
  name: `app-cluster-${environment}`,
});
 
const taskDef = new aws.ecs.TaskDefinition("app-task", {
  family: "app",
  networkMode: "awsvpc",
  requiresCompatibilities: ["FARGATE"],
  cpu: "256",
  memory: "512",
  containerDefinitions: pulumi.all([repo.repositoryUrl, db.endpoint, dbPassword]).apply(
    ([repoUrl, dbEndpoint, password]) => JSON.stringify([{
      name: "app",
      image: `${repoUrl}:latest`,
      portMappings: [{ containerPort: 3000 }],
      environment: [
        { name: "DATABASE_URL", value: `postgresql://admin:${password}@${dbEndpoint}/appdb` }
      ],
    }])
  ),
});
 
// Security Group
const appSg = new aws.ec2.SecurityGroup("app-sg", {
  vpcId: vpc.vpcId,
  ingress: [{
    fromPort: 3000,
    toPort: 3000,
    protocol: "tcp",
    cidrBlocks: ["10.0.0.0/16"],
  }],
  egress: [{
    fromPort: 0,
    toPort: 0,
    protocol: "-1",
    cidrBlocks: ["0.0.0.0/0"],
  }],
});
 
// ECS Service
const service = new aws.ecs.Service("app-service", {
  cluster: cluster.arn,
  taskDefinition: taskDef.arn,
  desiredCount: environment === "prod" ? 3 : 1,
  launchType: "FARGATE",
  networkConfiguration: {
    subnets: vpc.privateSubnetIds,
    securityGroups: [appSg.id],
    assignPublicIp: false,
  },
});
 
// Outputs
export const vpcId = vpc.vpcId;
export const dbEndpoint = db.endpoint;
export const ecrUrl = repo.repositoryUrl;

Real-World Use Cases and Case Studies

Use Case 1: Microservices Platform

Pulumi shines when building platform abstractions because you can use real programming constructs:

// Pulumi: Reusable microservice component
class Microservice extends pulumi.ComponentResource {
  public readonly serviceUrl: pulumi.Output<string>;
  
  constructor(name: string, args: {
    image: string;
    port: number;
    replicas: number;
    cpu?: string;
    memory?: string;
    envVars?: Record<string, pulumi.Input<string>>;
  }, opts?: pulumi.ComponentResourceOptions) {
    super("custom:platform:Microservice", name, {}, opts);
    
    const taskDef = new aws.ecs.TaskDefinition(`${name}-task`, {
      family: name,
      requiresCompatibilities: ["FARGATE"],
      cpu: args.cpu || "256",
      memory: args.memory || "512",
      containerDefinitions: JSON.stringify([{
        name,
        image: args.image,
        portMappings: [{ containerPort: args.port }],
        environment: Object.entries(args.envVars || {}).map(([k, v]) => ({ name: k, value: v as string })),
      }]),
    }, { parent: this });
    
    const service = new aws.ecs.Service(`${name}-service`, {
      cluster: cluster.arn,
      taskDefinition: taskDef.arn,
      desiredCount: args.replicas,
      launchType: "FARGATE",
    }, { parent: this });
    
    this.serviceUrl = pulumi.interpolate`http://${service.name}:${args.port}`;
  }
}
 
// Deploy multiple microservices with one function call
const services = ["users", "orders", "products", "payments"].map(name => 
  new Microservice(name, {
    image: `${ecrUrl}/${name}:latest`,
    port: 3000,
    replicas: pulumi.getStack() === "prod" ? 3 : 1,
    envVars: {
      DATABASE_URL: dbEndpoint.apply(ep => `postgresql://${ep}/${name}`),
      REDIS_URL: redisEndpoint,
    },
  })
);

Use Case 2: Terraform CDK (Alternative)

If you want programming language benefits with Terraform's ecosystem, consider CDK for Terraform:

// CDKTF: TypeScript with Terraform
import { Construct } from "constructs";
import { App, TerraformStack } from "cdktf";
import { AwsProvider } from "@cdktf/provider-aws/lib/provider";
import { Instance } from "@cdktf/provider-aws/lib/instance";
 
class MyStack extends TerraformStack {
  constructor(scope: Construct, id: string) {
    super(scope, id);
    
    new AwsProvider(this, "aws", { region: "us-east-1" });
    
    for (let i = 0; i < 3; i++) {
      new Instance(this, `instance-${i}`, {
        ami: "ami-0c55b159cbfafe1f0",
        instanceType: "t3.micro",
        tags: { Name: `instance-${i}` },
      });
    }
  }
}
 
const app = new App();
new MyStack(app, "infrastructure");
app.synth();

Best Practices for Production

  1. Use remote state with locking β€” Never use local state in production. Configure S3 + DynamoDB for Terraform or use Pulumi Cloud for state management with built-in locking.

  2. Implement CI/CD for infrastructure β€” Automate plan/apply with GitHub Actions, GitLab CI, or similar. Run terraform plan or pulumi preview on pull requests for review.

  3. Use workspaces/stacks for environments β€” Separate dev, staging, and prod configurations with clear boundaries. Don't share state between environments.

  4. Implement policy as code β€” Use Terraform Sentinel, OPA (Open Policy Agent), or Pulumi CrossGuard to enforce compliance rules before deployment.

  5. Version pin everything β€” Pin provider versions, module versions, and tool versions to prevent unexpected changes during deployments.

  6. Use variables and configuration files β€” Never hardcode environment-specific values. Use variables (Terraform) or config (Pulumi) with appropriate validation.

  7. Implement drift detection β€” Regularly run terraform plan or pulumi preview to detect manual changes. Automate this with CI pipelines.

  8. Tag everything β€” Implement consistent tagging for cost allocation, ownership tracking, and compliance. Use default tags at the provider level.

Common Pitfalls and Solutions

PitfallImpactSolution
State file conflictsLost changes, corruptionUse remote state with locking (S3+DynamoDB for TF, Pulumi Cloud)
Provider version driftBreaking changes in productionPin provider versions in configuration
Secret exposure in stateSecurity breachUse secret management (Vault, AWS Secrets Manager) and mark sensitive outputs
Circular dependenciesDeployment failuresRestructure resources, use data sources
Large blast radiusAccidental resource deletionUse targeted applies, implement lifecycle rules
Drift between environmentsInconsistent deploymentsUse shared modules/components with environment-specific variables

Performance Optimization

# Terraform: Parallel resource creation
terraform {
  experiments = [module_variable_optional_attrs]
}
 
# Use -parallelism flag (default 10)
# terraform apply -parallelism=20
 
# Terraform: Targeted operations for faster applies
# terraform apply -target=aws_instance.web
// Pulumi: Parallel operations by default
// Resources without dependencies are created concurrently
 
// Use dynamic providers for custom logic
const customResource = new pulumi.dynamic.Resource("custom", {
  // ...
}, {
  // Custom provider that runs in parallel
  provider: new MyDynamicProvider(),
});

Comparison with Alternatives

FeatureTerraformPulumiAWS CDKCloudFormation
LanguageHCLTS/Python/Go/C#TS/Python/Java/C#YAML/JSON
State ManagementSelf-managedPulumi Cloud/Self-managedCloudFormationAWS-managed
Provider Ecosystem3000+ providers100+ providersAWS-onlyAWS-only
TestingSentinel, TerratestNative testing, Policy PacksCDK Assertionscfn-lint
Drift DetectionManual planManual previewCloudFormation driftBuilt-in
Learning CurveModerateLow (familiar language)LowLow
Multi-cloudExcellentExcellentLimitedAWS-only
CommunityVery largeGrowing fastLarge (AWS)Large (AWS)

Advanced Patterns and Techniques

// Pulumi: Dynamic configuration with transformations
import * as pulumi from "@pulumi/pulumi";
 
// Global transformation to add tags to all resources
const addTags = (args: pulumi.ResourceTransformationArgs) => {
  return {
    props: args.props,
    opts: pulumi.mergeOptions(args.opts, {
      transformations: [(args) => ({
        props: {
          ...args.props,
          tags: {
            ...args.props.tags,
            ManagedBy: "pulumi",
            Environment: pulumi.getStack(),
            Team: "platform",
          },
        },
        opts: args.opts,
      })],
    }),
  };
};
 
pulumi.runtime.registerStackTransformation(addTags);
# Terraform: Custom provider for internal APIs
terraform {
  required_providers {
    internal = {
      source  = "myorg/internal"
      version = "~> 1.0"
    }
  }
}
 
resource "internal_service" "api" {
  name     = "my-service"
  team     = "platform"
  language = "typescript"
}

Testing Strategies

// Pulumi unit testing
import * as pulumi from "@pulumi/pulumi";
import { Microservice } from "./microservice";
 
pulumi.runtime.setMocks({
  newResource: (args: pulumi.runtime.MockResourceArgs) => ({
    id: args.name + "_id",
    state: args.inputs,
  }),
  call: (args: pulumi.runtime.MockCallArgs) => args.inputs,
});
 
describe("Microservice", () => {
  it("creates ECS task with correct configuration", async () => {
    const service = new Microservice("test-service", {
      image: "test:latest",
      port: 3000,
      replicas: 2,
    });
    
    const taskDef = await service.taskDef.containerDefinitions.promise();
    const parsed = JSON.parse(taskDef);
    
    expect(parsed[0].image).toBe("test:latest");
    expect(parsed[0].portMappings[0].containerPort).toBe(3000);
  });
});
# Terraform testing with Terratest
# test/infrastructure_test.go
func TestTerraformInfrastructure(t *testing.T) {
  terraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
    TerraformDir: "../",
    Vars: map[string]interface{}{
      "environment": "test",
    },
  })
  
  defer terraform.Destroy(t, terraformOptions)
  terraform.InitAndApply(t, terraformOptions)
  
  instanceId := terraform.Output(t, terraformOptions, "instance_id")
  assert.NotEmpty(t, instanceId)
}

Future Outlook

Terraform continues to evolve with the OpenTofu fork (Linux Foundation) providing an open-source alternative after HashiCorp's license change to BSL. The ecosystem remains strong with thousands of providers and modules.

Pulumi is growing rapidly with Pulumi AI, Automation API for programmatic infrastructure management, and ESC (Environments, Secrets, and Configuration) for centralized configuration. The trend toward using general-purpose languages for IaC is accelerating.

Conclusion

Both Terraform and Pulumi are excellent IaC tools with different strengths:

  1. Choose Terraform if you have existing HCL expertise, need the broadest provider ecosystem, or prefer a declarative DSL purpose-built for infrastructure
  2. Choose Pulumi if your team prefers general-purpose languages, you need complex logic in infrastructure code, or you want better abstractions and reuse patterns
  3. Both tools support multi-cloud, have strong state management, and integrate well with CI/CD pipelines
  4. Start small β€” pilot with a non-critical service before committing to organization-wide adoption
  5. Consider CDKTF as a middle ground that combines TypeScript with Terraform's ecosystem

The best choice depends on your team's skills, existing infrastructure, and organizational needs. Both tools will serve you well in productionβ€”the key is consistency and good engineering practices regardless of which you choose.