Terraform vs Pulumi vs CloudFormation: IaC Decision Guide

Compare infrastructure-as-code tools for enterprise cloud deployments. Covers language support, state management, multi-cloud capabilities, and organizational fit.

Terraform, Pulumi, and AWS CloudFormation are the dominant tools for infrastructure as code (IaC), but choosing among them is not a matter of preference—it’s a decision about how your team collaborates, how infrastructure evolves, and how resilience is built into your deployment pipelines. This guide maps the real differences in behavior, workflow, and failure modes that emerge when you use these tools in production. It answers: When does Terraform win over Pulumi? When does CloudFormation’s simplicity pay off? What happens when you misconfigure a single field in a Pulumi program, and how does it reveal itself across your stack?

When to Choose Terraform

Terraform excels when infrastructure spans multiple cloud providers and requires declarative, versioned state. It is the default for multi-cloud, hybrid, and long-lived environments.

The core command is terraform apply, which reads the current state and computes a plan from the *.tf files. A terraform plan -out=prod.tfplan generates a plan file that can be reviewed and versioned. The state is stored in a backend—typically remote, s3, or consul. A typical backend.tf block:

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

State drift is a common failure mode. When a manual change is made in the cloud (e.g., a new tag on an EC2 instance), terraform plan reports it but does not change anything; apply is what reconciles it. Both plan and apply refresh state automatically before computing a diff, so there is no separate refresh step to forget — the standalone terraform refresh command has been deprecated since 0.15.4 in favour of terraform apply -refresh-only, which shows the detected state changes and asks for confirmation. The error is visible in the plan output:

Plan: 3 to apply, 1 to update, 0 to destroy
  + module.vpc.aws_security_group.default
    - tags.Name: "vpc-internal" → "vpc-internal (updated)"
    - tags.Environment: "prod" → "prod (updated)"
    + tags.ManagedBy: "Terraform" → "Terraform"

The terraform apply -input=false -auto-approve pattern is essential for CI/CD. A typical GitHub Actions workflow uses terraform init, terraform plan, and terraform apply with TF_VAR_* environment variables passed from secrets.

Terraform’s module system enables reusable components. A module is a directory with main.tf, variables.tf, and outputs.tf. When calling a module:

module "web" {
  source  = "./modules/web-server"
  instance_count = 4
  ami_id         = "ami-123456"
  subnet_id      = aws_subnet.app.id
}

The terraform graph command generates a visual dependency graph, which is invaluable during onboarding and debugging. The terraform fmt command enforces consistent formatting across teams, and terraform validate checks syntax and input types before any plan.

Terraform’s biggest edge is in state management. State is the single source of truth, and its evolution is predictable. But it silently fails when state is out of sync. A terraform state pull followed by terraform apply in a CI pipeline can fail if the backend is unreachable. The error is:

Error: Failed to load state: failed to fetch remote state from S3
  Error: NoSuchKey: The specified key does not exist

This happens when the key field in backend.tf is misspelled or when the state file was never uploaded.

When to Choose Pulumi

Pulumi shines when infrastructure logic is complex, and your team is already proficient in programming languages. It treats infrastructure as code in JavaScript, Python, TypeScript, or Go. The core command is pulumi up to deploy a stack.

A simple Pulumi program in TypeScript:

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

const vpc = new aws.ec2.Vpc("web-vpc", {
  cidrBlock: "10.0.0.0/16",
  enableDnsHostnames: true,
  enableDnsSupport: true,
});

const sg = new aws.ec2.SecurityGroup("web-sg", {
  vpcId: vpc.id,
  description: "Allow HTTP and SSH",
  ingress: [
    { protocol: "tcp", fromPort: 80, toPort: 80, cidrBlocks: ["0.0.0.0/0"] },
    { protocol: "tcp", fromPort: 22, toPort: 22, cidrBlocks: ["0.0.0.0/0"] },
  ],
});

export const vpcId = vpc.id;
export const sgId = sg.id;

Pulumi uses a Pulumi.yaml file to define the stack and its configuration:

name: production
description: "Production infrastructure for web app"
runtime: nodejs
config:
  aws:region: us-west-2
  web:instanceType: t3.medium

Configuration is accessed via pulumi config get <key> and pulumi config set <key> <value>. The pulumi stack command manages multiple environments:

pulumi stack init staging
pulumi config set aws:region us-east-1
pulumi up --stack staging

Pulumi’s greatest strength is imperative logic. You can loop, conditionally create resources, and use external data. For example, to create EC2 instances from a list of AMIs:

const amImageIds = ["ami-123", "ami-456", "ami-789"];

amImageIds.forEach((ami, i) => {
  new aws.ec2.Instance(`web-server-${i}`, {
    instanceType: "t3.small",
    ami: ami,
    securityGroups: [sg.id],
    userData: `echo "Starting server ${i}"`,
  });
});

Pulumi also supports custom resources. You define a resource type in code and use it like any built-in:

class DatabaseBackup extends pulumi.CustomResource {
  constructor(name: string, args: DatabaseBackupArgs, opts?: pulumi.CustomResourceOptions) {
    super("backup:Database", name, args, opts);
  }
}

const backup = new DatabaseBackup("daily", {
  database: "prod-mysql",
  retentionDays: 30,
});

Pulumi’s failure mode is stack drift. When a resource is modified in the cloud (e.g., by a script), pulumi up diffs against the last recorded state rather than the live cloud, so an out-of-band change goes unnoticed until pulumi refresh reconciles state with reality. The pulumi stack export command outputs the current state as JSON, and pulumi stack import can restore it. A common mistake is relying on pulumi up alone after someone changes a resource by hand: without a pulumi refresh first, the recorded state and the live cloud diverge silently. The --diff flag only controls how much detail the diff display shows; it does not reconcile state.

Pulumi’s pulumi preview command shows a detailed diff, including output values. The pulumi stack rm command removes the stack and its state file, not the infrastructure the stack created. Run pulumi destroy first to tear the resources down, or they are left orphaned in the cloud with no state tracking them.

When to Choose AWS CloudFormation

CloudFormation is the native IaC tool for AWS, and it excels when infrastructure is AWS-centric and the team prefers JSON or YAML over programming languages.

The primary command is aws cloudformation create-stack or update-stack. A template is a JSON or YAML file defining the infrastructure:

AWSTemplateFormatVersion: "2010-09-09"
Description: "A web server stack"
Resources:
  WebServer:
    Type: AWS::EC2::Instance
    Properties:
      ImageId: ami-123456
      InstanceType: t3.small
      SecurityGroups:
        - Ref: WebSecurityGroup
      UserData:
        Fn::Base64: !Sub |
          #!/bin/bash
          echo "Hello from CloudFormation"
  WebSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: "Allow HTTP and SSH"
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: 80
          ToPort: 80
          CidrIp: 0.0.0.0/0
        - IpProtocol: tcp
          FromPort: 22
          ToPort: 22
          CidrIp: 0.0.0.0/0
Outputs:
  WebServerPublicDns:
    Description: "Public DNS of the web server"
    Value: !GetAtt WebServer.PublicDnsName
    Export:
      Name: WebServerPublicDns

The aws cloudformation deploy command combines create-stack and update-stack with a --template-body or --template-file argument. It supports --parameter-overrides, --capabilities, and --stack-name:

aws cloudformation deploy \
  --template-file template.yaml \
  --stack-name web-prod \
  --parameter-overrides Environment=prod InstanceType=t3.large \
  --capabilities CAPABILITY_IAM CAPABILITY_NAMED_IAM

CloudFormation’s greatest strength is stack updates. It uses a change set to preview changes before applying them. You can create a change set with:

aws cloudformation create-change-set \
  --stack-name web-prod \
  --change-set-name update-2023 \
  --template-body file://template.yaml \
  --parameters ParameterKey=Environment,ParameterValue=staging

Then you can review it:

aws cloudformation describe-change-set --change-set-name update-2023

The change set includes the list of resources to be created, updated, or deleted, along with detailed diffs. You can then execute it with execute-change-set.

CloudFormation silently fails when a resource fails to create. The aws cloudformation describe-stacks command returns a list of stacks with their status (CREATE_IN_PROGRESS, UPDATE_COMPLETE, ROLLBACK_COMPLETE). A stack failure appears as:

StackStatus: ROLLBACK_COMPLETE
StackStatusReason: The following resource(s) failed to create: WebServer, WebSecurityGroup

The error is not in the stack output but in the Events section:

aws cloudformation describe-stack-events --stack-name web-prod

A common failure mode is nested stacks. When a stack includes another stack, the parent stack waits for the child stack to complete. If the child stack fails, the parent rolls back. But the child stack’s Outputs are not available until it succeeds. A Fn::GetAtt call on a nested stack can fail if the child stack is still in CREATE_IN_PROGRESS.

CloudFormation also supports drift detection. You can run aws cloudformation detect-stack-drift to compare the current state of a stack with its template. The result is a list of drifted resources, where the actual properties differ from the template.

When to Mix Them

No tool is perfect. Teams often use all three in tandem. A typical workflow:

A Pulumi program can deploy a CloudFormation stack using the aws.cloudformation.Stack resource. A Terraform module can be used in a Pulumi program via pulumi-terraform.

A common failure is state inconsistency across tools. A CloudFormation stack may change a parameter, but Terraform does not know about it until its next plan or apply refreshes state. A Pulumi stack may create an S3 bucket, but CloudFormation does not track it unless it’s explicitly included.

The decision to use one tool over another is not about features, but about where the team’s mental model resides: in declarative state (Terraform), in code (Pulumi), or in AWS-native JSON/YAML (CloudFormation). The right tool is not the one with the most features, but the one that reduces cognitive load across the entire infrastructure lifecycle.

This page was rewritten on 17 October 2026. It replaced a templated version that was not specific to its own title. How these pages are written and checked. If anything here is wrong, tell us at [email protected] and we will correct it.