Terraform vs CloudFormation vs Pulumi: Infrastructure as Code Comparison for 2026

Hands-on comparison of Terraform, CloudFormation, and Pulumi for 2026. Real configs, migration paths, and when to choose each IaC tool.

Terraform vs CloudFormation vs Pulumi: Infrastructure as Code Comparison for 2026

I have deployed infrastructure with all three of these tools in production environments — Terraform at a defense contractor managing 200+ AWS accounts, CloudFormation at a healthcare company running HIPAA-regulated workloads, and Pulumi at a startup that wanted its Python developers writing infrastructure without learning a new language. Each tool made specific tradeoffs that became visible only after six months of production use.

This is not a feature checklist comparison. Those exist everywhere and tell you nothing about what happens when your team hits 500 resources, when your state file corrupts at 2 AM, or when you need to import 80 existing resources into management. This is a comparison based on operating each tool under real constraints.

The Fundamental Architectural Difference

Before comparing features, understand what each tool actually does at the engine level:

Terraform (HashiCorp, now IBM) uses a declarative configuration language called HCL. You describe the desired state. Terraform builds a dependency graph, compares desired state to current state (stored in a state file), and generates a plan of API calls to reconcile the difference. Terraform is cloud-agnostic — it has providers for AWS, Azure, GCP, Kubernetes, Datadog, PagerDuty, GitHub, and 3,000+ other services.

CloudFormation (AWS) uses declarative YAML or JSON templates. You submit a template to the CloudFormation service, which runs inside AWS's infrastructure. CloudFormation manages state internally — you never touch a state file. It is AWS-only by design, though AWS created the Cloud Control API (CCAPI) as a bridge for third-party resources.

Pulumi uses general-purpose programming languages (TypeScript, Python, Go, C#, Java, YAML). You write infrastructure definitions in the same language as your application. Pulumi compiles this into a desired-state model and reconciles it against current state, similar to Terraform. Pulumi is cloud-agnostic and shares Terraform's provider ecosystem through a bridging layer called pulumi-terraform-bridge.

The difference matters because it determines your failure modes. Terraform fails when HCL cannot express your logic. CloudFormation fails when AWS does not support a resource type or property. Pulumi fails when your programming language abstractions leak into infrastructure state.

Side-by-Side: The Same Infrastructure, Three Ways

Here is an AWS VPC with public and private subnets, a NAT Gateway, and an Application Load Balancer — the backbone of most production architectures — in all three tools.

Terraform (HCL)

# providers.tf
terraform {
  required_version = ">= 1.8.0"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.40"
    }
  }
  backend "s3" {
    bucket         = "citadel-terraform-state"
    key            = "prod/vpc/terraform.tfstate"
    region         = "us-east-1"
    dynamodb_table = "terraform-locks"
    encrypt        = true
  }
}

# vpc.tf
resource "aws_vpc" "main" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_hostnames = true
  enable_dns_support   = true

  tags = {
    Name        = "citadel-prod-vpc"
    Environment = "production"
    ManagedBy   = "terraform"
  }
}

resource "aws_subnet" "public" {
  count             = 2
  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]

  map_public_ip_on_launch = true

  tags = {
    Name = "citadel-public-${data.aws_availability_zones.available.names[count.index]}"
    Tier = "public"
  }
}

resource "aws_subnet" "private" {
  count             = 2
  vpc_id            = aws_vpc.main.id
  cidr_block        = cidrsubnet(aws_vpc.main.cidr_block, 8, count.index + 10)
  availability_zone = data.aws_availability_zones.available.names[count.index]

  tags = {
    Name = "citadel-private-${data.aws_availability_zones.available.names[count.index]}"
    Tier = "private"
  }
}

resource "aws_nat_gateway" "main" {
  allocation_id = aws_eip.nat.id
  subnet_id     = aws_subnet.public[0].id

  tags = {
    Name = "citadel-nat-gateway"
  }
}

resource "aws_lb" "app" {
  name               = "citadel-alb"
  internal           = false
  load_balancer_type = "application"
  security_groups    = [aws_security_group.alb.id]
  subnets            = aws_subnet.public[*].id

  enable_deletion_protection = true
}

CloudFormation (YAML)

AWSTemplateFormatVersion: '2010-09-09'
Description: Citadel Production VPC with ALB

Parameters:
  Environment:
    Type: String
    Default: production
    AllowedValues: [production, staging, development]

Resources:
  VPC:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: '10.0.0.0/16'
      EnableDnsHostnames: true
      EnableDnsSupport: true
      Tags:
        - Key: Name
          Value: citadel-prod-vpc
        - Key: Environment
          Value: !Ref Environment

  PublicSubnet1:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref VPC
      CidrBlock: '10.0.0.0/24'
      AvailabilityZone: !Select [0, !GetAZs '']
      MapPublicIpOnLaunch: true
      Tags:
        - Key: Name
          Value: citadel-public-1

  PublicSubnet2:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref VPC
      CidrBlock: '10.0.1.0/24'
      AvailabilityZone: !Select [1, !GetAZs '']
      MapPublicIpOnLaunch: true
      Tags:
        - Key: Name
          Value: citadel-public-2

  PrivateSubnet1:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref VPC
      CidrBlock: '10.0.10.0/24'
      AvailabilityZone: !Select [0, !GetAZs '']
      Tags:
        - Key: Name
          Value: citadel-private-1

  PrivateSubnet2:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref VPC
      CidrBlock: '10.0.11.0/24'
      AvailabilityZone: !Select [1, !GetAZs '']
      Tags:
        - Key: Name
          Value: citadel-private-2

  NATGateway:
    Type: AWS::EC2::NatGateway
    Properties:
      AllocationId: !GetAtt NATGatewayEIP.AllocationId
      SubnetId: !Ref PublicSubnet1

  ApplicationLoadBalancer:
    Type: AWS::ElasticLoadBalancingV2::LoadBalancer
    Properties:
      Name: citadel-alb
      Type: application
      Scheme: internet-facing
      SecurityGroups:
        - !Ref ALBSecurityGroup
      Subnets:
        - !Ref PublicSubnet1
        - !Ref PublicSubnet2

Outputs:
  VPCId:
    Value: !Ref VPC
    Export:
      Name: !Sub '${AWS::StackName}-VPCId'
  ALBDNSName:
    Value: !GetAtt ApplicationLoadBalancer.DNSName

Pulumi (TypeScript)

import * as pulumi from "@pulumi/pulumi";
import * as aws from "@pulumi/aws";

const config = new pulumi.Config();
const environment = config.get("environment") || "production";

const vpc = new aws.ec2.Vpc("citadel-vpc", {
  cidrBlock: "10.0.0.0/16",
  enableDnsHostnames: true,
  enableDnsSupport: true,
  tags: {
    Name: "citadel-prod-vpc",
    Environment: environment,
    ManagedBy: "pulumi",
  },
});

const azs = aws.getAvailabilityZones({ state: "available" });

const publicSubnets = azs.then(azs =>
  azs.names.slice(0, 2).map((az, index) =>
    new aws.ec2.Subnet(`citadel-public-${index}`, {
      vpcId: vpc.id,
      cidrBlock: `10.0.${index}.0/24`,
      availabilityZone: az,
      mapPublicIpOnLaunch: true,
      tags: { Name: `citadel-public-${az}`, Tier: "public" },
    })
  )
);

const privateSubnets = azs.then(azs =>
  azs.names.slice(0, 2).map((az, index) =>
    new aws.ec2.Subnet(`citadel-private-${index}`, {
      vpcId: vpc.id,
      cidrBlock: `10.0.${index + 10}.0/24`,
      availabilityZone: az,
      tags: { Name: `citadel-private-${az}`, Tier: "private" },
    })
  )
);

const eip = new aws.ec2.Eip("citadel-nat-eip", { domain: "vpc" });

const natGateway = new aws.ec2.NatGateway("citadel-nat", {
  allocationId: eip.id,
  subnetId: publicSubnets.then(s => s[0].id),
  tags: { Name: "citadel-nat-gateway" },
});

const alb = new aws.lb.LoadBalancer("citadel-alb", {
  internal: false,
  loadBalancerType: "application",
  securityGroups: [albSecurityGroup.id],
  subnets: publicSubnets.then(s => s.map(subnet => subnet.id)),
  enableDeletionProtection: true,
});

export const vpcId = vpc.id;
export const albDnsName = alb.dnsName;

Where Each Tool Wins and Loses in Production

State Management: The Make-or-Break Factor

Terraform stores state in a file. By default, this is local (terraform.tfstate). In production, you use a remote backend — S3 + DynamoDB for locking is the standard for AWS. The state file contains every resource attribute, including sensitive values like database passwords and access keys. This file must be encrypted at rest and in transit, and access must be restricted.

State file corruption is Terraform's worst failure mode. I have seen it happen three times in production: - Once when two engineers ran terraform apply simultaneously before we configured DynamoDB locking - Once when an S3 bucket had versioning disabled and a partial write corrupted the file - Once during a Terraform version upgrade that changed the state schema

Recovery involves terraform state pull, manual JSON editing, and terraform state push. It is not fun at 3 AM. The mitigation: always enable S3 versioning on your state bucket, always use DynamoDB locking, and always run Terraform through CI/CD (never from laptops).

CloudFormation manages state internally. You never see it. You never corrupt it. This is CloudFormation's single greatest advantage. When a stack update fails, CloudFormation automatically rolls back to the previous known-good state. You cannot corrupt what you cannot touch.

The downside: you cannot inspect or modify state directly. If CloudFormation's internal state drifts from reality (someone modifies a resource through the console), drift detection can identify it but cannot always fix it. Some resource types do not support drift detection at all.

Pulumi stores state in the Pulumi Cloud service by default (free for individual use, paid for teams). You can self-host state in S3 or Azure Blob Storage. The state model is similar to Terraform — a JSON file tracking every resource. The same corruption risks apply, with the same mitigations. Pulumi Cloud handles locking and versioning automatically, which removes the DynamoDB locking requirement.

Multi-Cloud Reality

Terraform is the clear winner for multi-cloud deployments. At the defense contractor, we managed AWS accounts, Azure subscriptions, GCP projects, Datadog monitors, PagerDuty schedules, and GitHub repositories — all from the same Terraform codebase. The provider model is consistent: every provider exposes resources and data sources with the same lifecycle (create, read, update, delete).

# Same codebase, different providers
provider "aws" { region = "us-east-1" }
provider "azurerm" { features {} }
provider "google" { project = "citadel-prod" region = "us-central1" }

# Deploy to all three from one terraform apply
resource "aws_s3_bucket" "data" { bucket = "citadel-data" }
resource "azurerm_storage_account" "data" { name = "citadeldata" ... }
resource "google_storage_bucket" "data" { name = "citadel-data" location = "US" }

CloudFormation is AWS-only. AWS introduced the Cloud Control API and AWS::CloudFormation::TypeActivation to register third-party resource types, but the ecosystem is thin. If you run exclusively on AWS and have no plans to use another cloud, CloudFormation eliminates the state management burden. But "no plans to use another cloud" is a business decision that changes frequently.

Pulumi matches Terraform's multi-cloud coverage because it bridges Terraform providers. In practice, the bridged providers sometimes lag behind Terraform provider releases by days to weeks. For AWS, Azure, and GCP core services, this lag is negligible. For niche providers, it can matter.

Language Complexity and Team Onboarding

Terraform HCL is a domain-specific language with roughly 30 keywords. An infrastructure engineer can become productive in HCL within a week. The language deliberately limits what you can do — no arbitrary loops, limited conditionals (the for_each and count meta-arguments), no general-purpose functions. This constraint is a feature: HCL code from different teams looks similar because the language does not permit wild variation.

The constraint becomes a problem when you need complex logic. Generating dynamic configurations — for example, creating a map of IAM policies based on a list of teams where each team has different permissions — pushes HCL to its limits. You end up with nested for expressions, try() functions, and locals blocks that are harder to read than the equivalent Python.

CloudFormation YAML is verbose. The VPC example above is 30% longer in CloudFormation than in Terraform. CloudFormation's intrinsic functions (!Ref, !Sub, !GetAtt, !Select, !If) are functional programming crammed into YAML. Complex templates with nested conditions become unreadable. AWS addressed this with CDK (Cloud Development Kit), which lets you write CloudFormation in TypeScript, Python, Java, C#, or Go. CDK synthesizes to CloudFormation YAML, meaning you get programming language ergonomics with CloudFormation's state management.

Pulumi uses real programming languages. This is simultaneously its greatest strength and greatest risk. TypeScript developers can write infrastructure immediately. They can use interfaces, generics, async/await, and npm packages. They can write unit tests with Jest. They can share infrastructure modules as npm packages.

The risk: they write infrastructure code that looks like application code. I have seen Pulumi codebases with class hierarchies four levels deep, custom decorators for tagging, and abstract factory patterns for resource creation. The infrastructure was correct but unmaintainable by anyone who did not understand the abstractions. Good Pulumi code is flat and explicit. Treat it like configuration that happens to be in TypeScript, not like an application.

Ecosystem and Module Reuse

Terraform has the largest module ecosystem. The Terraform Registry hosts 15,000+ modules and 4,000+ providers. The terraform-aws-modules organization alone provides battle-tested modules for VPC, EKS, RDS, Lambda, and dozens of other services. These modules encode AWS best practices and save weeks of work.

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

  name = "citadel-prod"
  cidr = "10.0.0.0/16"

  azs             = ["us-east-1a", "us-east-1b", "us-east-1c"]
  private_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
  public_subnets  = ["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"]

  enable_nat_gateway = true
  single_nat_gateway = false
}

CloudFormation has AWS-maintained "Quick Start" templates (now called "Partner Solutions") and the Serverless Application Model (SAM) for Lambda-based architectures. The ecosystem is smaller than Terraform's, but every resource type is maintained by AWS and guaranteed to work with the latest API versions on launch day.

Pulumi publishes component resources (higher-level abstractions) in its registry and supports sharing modules as standard language packages (npm, PyPI, Go modules). The ecosystem is growing but smaller than Terraform's. Pulumi's bridging layer means you can use the same provider coverage as Terraform, but community-built modules are less abundant.

CI/CD Integration

All three tools integrate with GitHub Actions, GitLab CI, Jenkins, and other CI/CD systems. The patterns differ:

Terraform CI/CD Pattern:

# GitHub Actions
- terraform init
- terraform plan -out=tfplan     # Generate plan
- terraform show -json tfplan    # Export for review
# Manual approval gate
- terraform apply tfplan          # Apply approved plan

CloudFormation CI/CD Pattern:

# GitHub Actions
- aws cloudformation deploy \
    --template-file template.yaml \
    --stack-name citadel-prod \
    --capabilities CAPABILITY_IAM \
    --no-fail-on-empty-changeset
# CloudFormation handles rollback automatically on failure

Pulumi CI/CD Pattern:

# GitHub Actions
- pulumi preview                 # Generate plan
# Manual approval gate
- pulumi up --yes                # Apply

CloudFormation's built-in rollback on failure is a significant operational advantage. If a Terraform apply fails midway, you have a partially applied state that requires manual intervention. CloudFormation rolls back the entire stack to the previous version.

Decision Framework: Which Tool for Which Scenario

Scenario Recommended Tool Reasoning
AWS-only shop, small team (<5 engineers) CloudFormation + CDK Zero state management burden, native AWS integration, CDK provides programming language ergonomics
Multi-cloud or hybrid (AWS + Azure/GCP) Terraform Largest provider ecosystem, consistent workflow across clouds, mature module library
Development team writes TypeScript/Python, minimal ops staff Pulumi No new language to learn, unit-testable infrastructure, familiar tooling (npm, pip)
Heavily regulated (FedRAMP, HIPAA, SOC 2) Terraform or CloudFormation Both have audit trail capabilities; CloudFormation's automatic rollback reduces compliance risk; Terraform's plan-and-apply with approval gates satisfies change management requirements
Existing Terraform codebase, team wants to modernize Stay with Terraform Migration cost rarely justified; invest in modules, CI/CD, and Terraform Cloud/Enterprise instead
Kubernetes-heavy infrastructure Terraform + Helm or Pulumi Both handle Kubernetes resources natively; Pulumi's Kubernetes provider is particularly strong for teams already using TypeScript

The Migration Question

I am asked weekly whether teams should migrate from CloudFormation to Terraform or vice versa. My answer is almost always no. Migration has three costs that are consistently underestimated:

  1. State import: Every existing resource must be imported into the new tool's state. For a 500-resource environment, this is 2-4 weeks of engineering time.
  2. Testing: Every imported resource must be verified. One wrong attribute in state causes drift on the next apply.
  3. Team retraining: Engineers need 4-8 weeks to become productive in a new IaC tool.

Migrate only when your current tool is blocking a business requirement that the target tool solves. "Terraform is more popular" is not a business requirement.

Master Infrastructure as Code Hands-On

The DevOps Terraform course on Citadel Cloud Management covers Terraform from first init to production-grade module design across AWS, Azure, and GCP. It includes state management strategies, CI/CD integration patterns, and the exact module structures used in enterprise environments. Free enrollment, 65+ hours of content.

For ready-to-use infrastructure templates, Terraform modules, and CloudFormation stacks tested in production environments, browse the DevOps Tools collection. Each toolkit includes documentation, variable definitions, and deployment guides.

For teams adopting IaC across their organization — with standardized module libraries, policy-as-code guardrails, and multi-account landing zone templates — the Enterprise Bundles collection packages everything needed for a complete infrastructure automation rollout.

Pick the tool that fits your team's constraints today. Then invest in mastering it deeply rather than switching tools every year. The best IaC tool is the one your team uses consistently.

Kehinde Ogunlowo

Senior Multi-Cloud DevSecOps Architect & AI Engineer

AWS, Azure, GCP Certified | Secret Clearance | FedRAMP, CMMC, HIPAA

Enterprise experience at Cigna Healthcare, Lockheed Martin, NantHealth, BP Refinery, and Patterson UTI.

Start Your Cloud Career Today

Access 17 free courses covering AWS, Azure, GCP, DevOps, AI/ML, and cloud security — built by a practicing Senior Cloud Architect with enterprise experience.

Get Free Cloud Career Resources

You might also like