Categories
Cloud DevOps & Cloud Infrastructure Software Development

Infrastructure as Code: Terraform vs Pulumi vs CloudFormation

Explore Terraform, Pulumi, and CloudFormation for AWS infrastructure deployment. Learn syntax, state management, and the best fit for your stack.

If you’re deploying cloud infrastructure at scale, you need to automate it. That’s where Infrastructure as Code (IaC) comes in. But with several mature tools—Terraform, Pulumi, and AWS CloudFormation—which one should you use? This guide compares all three by deploying the same AWS stack (VPC, EC2, RDS) with each. You’ll see the real syntax, state management tradeoffs, and what the learning curve looks like when applying these tools to production workloads.

Key Takeaways:

  • See practical examples of deploying a VPC, EC2, and RDS instance with Terraform, Pulumi, and CloudFormation
  • Understand the real syntax, workflow, and state management differences between the tools
  • Evaluate the learning curve and team impact for each approach
  • Spot common mistakes and find best practices for IaC in production
  • Decide which IaC tool fits your stack, team, and operational requirements

Overview of IaC Tools: Terraform, Pulumi, CloudFormation

Infrastructure as Code lets you define, provision, and manage cloud resources using code. The three major players for AWS infrastructure are:

ToolLanguageMulti-CloudState ManagementCommunity/Support
TerraformHCL (HashiCorp Config Language)YesLocal/Remote (backend)Very Active
PulumiGeneral-purpose (TypeScript, Python, Go, etc.)YesLocal/Cloud (Pulumi Service or S3, etc.)Active, smaller than Terraform
CloudFormationYAML/JSONNo (AWS only)Managed by AWSNative AWS support

All three tools let you codify infrastructure, run diffs/plans, and automate deployment. But their syntax, workflow, and extensibility differ sharply. If you’re running Kubernetes, you may also want to check out GitOps with ArgoCD for Kubernetes-native IaC approaches.

Real-World Deployment: VPC + EC2 + RDS with Each Tool

Let’s deploy a minimal but production-relevant AWS setup:

  • One VPC (10.0.0.0/16)
  • Public subnet
  • EC2 instance (Amazon Linux 2)
  • RDS instance (PostgreSQL, single-AZ, public access disabled)

Terraform Example (HCL)

# main.tf
provider "aws" {
  region = "us-east-1"
}

resource "aws_vpc" "main" {
  cidr_block = "10.0.0.0/16"
  tags = { Name = "iac-demo-vpc" }
}

resource "aws_subnet" "public" {
  vpc_id                  = aws_vpc.main.id
  cidr_block              = "10.0.1.0/24"
  map_public_ip_on_launch = true
  availability_zone       = "us-east-1a"
}

resource "aws_security_group" "ec2_sg" {
  vpc_id = aws_vpc.main.id
  ingress {
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
  tags = { Name = "ec2-sg" }
}

resource "aws_instance" "web" {
  ami           = "ami-0c02fb55956c7d316" # Amazon Linux 2 (check for updates)
  instance_type = "t3.micro"
  subnet_id     = aws_subnet.public.id
  vpc_security_group_ids = [aws_security_group.ec2_sg.id]
  tags = { Name = "iac-demo-ec2" }
}

resource "aws_db_subnet_group" "default" {
  name       = "iac-demo-db-subnet"
  subnet_ids = [aws_subnet.public.id]
}

resource "aws_db_instance" "postgres" {
  allocated_storage    = 20
  engine               = "postgres"
  engine_version       = "14.7"
  instance_class       = "db.t3.micro"
  db_subnet_group_name = aws_db_subnet_group.default.name
  vpc_security_group_ids = [aws_security_group.ec2_sg.id]
  username             = "masteruser"
  password             = "CHANGEME123!" # Use secrets in production!
  skip_final_snapshot  = true
  publicly_accessible  = false
  tags = { Name = "iac-demo-rds" }
}

Commands:

You landed the Cloud Storage of the future internet. Cloud Storage Services Sesame Disk by NiHao Cloud

Use it NOW and forever!

Support the growth of a Team File sharing system that works for people in China, USA, Europe, APAC and everywhere else.
# Initialize and apply
terraform init
terraform plan
terraform apply

This HCL code is straightforward. Resources are strongly typed and dependencies are explicit. You need to configure remote state for production use (see later section).

Pulumi Example (TypeScript)

// index.ts
import * as aws from "@pulumi/aws";

const vpc = new aws.ec2.Vpc("iac-demo-vpc", {
    cidrBlock: "10.0.0.0/16",
    tags: { Name: "iac-demo-vpc" },
});

const subnet = new aws.ec2.Subnet("public", {
    vpcId: vpc.id,
    cidrBlock: "10.0.1.0/24",
    availabilityZone: "us-east-1a",
    mapPublicIpOnLaunch: true,
});

const ec2Sg = new aws.ec2.SecurityGroup("ec2-sg", {
    vpcId: vpc.id,
    ingress: [{
        protocol: "tcp",
        fromPort: 22,
        toPort: 22,
        cidrBlocks: ["0.0.0.0/0"],
    }],
    egress: [{
        protocol: "-1",
        fromPort: 0,
        toPort: 0,
        cidrBlocks: ["0.0.0.0/0"],
    }],
    tags: { Name: "ec2-sg" },
});

const ec2 = new aws.ec2.Instance("iac-demo-ec2", {
    ami: "ami-0c02fb55956c7d316",
    instanceType: "t3.micro",
    subnetId: subnet.id,
    vpcSecurityGroupIds: [ec2Sg.id],
    tags: { Name: "iac-demo-ec2" },
});

const dbSubnetGroup = new aws.rds.SubnetGroup("db-subnet", {
    subnetIds: [subnet.id],
    tags: { Name: "iac-demo-db-subnet" }
});

const rds = new aws.rds.Instance("postgres", {
    allocatedStorage: 20,
    engine: "postgres",
    engineVersion: "14.7",
    instanceClass: "db.t3.micro",
    dbSubnetGroupName: dbSubnetGroup.name,
    vpcSecurityGroupIds: [ec2Sg.id],
    username: "masteruser",
    password: "CHANGEME123!", // Use Pulumi secrets for real
    skipFinalSnapshot: true,
    publiclyAccessible: false,
    tags: { Name: "iac-demo-rds" },
});

Commands:

# Install dependencies
npm install @pulumi/pulumi @pulumi/aws

# Login to state backend (local or Pulumi Service)
pulumi login

# Preview and deploy
pulumi preview
pulumi up

Pulumi lets you use a familiar language (TypeScript here). You can leverage loops, conditionals, and code reuse like any app. Pulumi’s state can be managed locally or in their SaaS (with RBAC). For production, use Pulumi Service or an S3 backend with encryption and versioning enabled.

CloudFormation Example (YAML)

# vpc-ec2-rds.yaml
AWSTemplateFormatVersion: '2010-09-09'
Resources:
  VPC:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: 10.0.0.0/16
      Tags: [{ Key: Name, Value: "iac-demo-vpc" }]
  PublicSubnet:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref VPC
      CidrBlock: 10.0.1.0/24
      AvailabilityZone: us-east-1a
      MapPublicIpOnLaunch: true
  EC2SecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: "Allow SSH"
      VpcId: !Ref VPC
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: 22
          ToPort: 22
          CidrIp: 0.0.0.0/0
      SecurityGroupEgress:
        - IpProtocol: -1
          FromPort: 0
          ToPort: 0
          CidrIp: 0.0.0.0/0
      Tags: [{ Key: Name, Value: "ec2-sg" }]
  EC2Instance:
    Type: AWS::EC2::Instance
    Properties:
      ImageId: ami-0c02fb55956c7d316
      InstanceType: t3.micro
      SubnetId: !Ref PublicSubnet
      SecurityGroupIds: [!Ref EC2SecurityGroup]
      Tags: [{ Key: Name, Value: "iac-demo-ec2" }]
  DBSubnetGroup:
    Type: AWS::RDS::DBSubnetGroup
    Properties:
      DBSubnetGroupDescription: "iac-demo-db-subnet"
      SubnetIds: [!Ref PublicSubnet]
      Tags: [{ Key: Name, Value: "iac-demo-db-subnet" }]
  RDSInstance:
    Type: AWS::RDS::DBInstance
    Properties:
      AllocatedStorage: 20
      DBInstanceClass: db.t3.micro
      Engine: postgres
      EngineVersion: "14.7"
      MasterUsername: masteruser
      MasterUserPassword: CHANGEME123!
      DBSubnetGroupName: !Ref DBSubnetGroup
      VPCSecurityGroups: [!Ref EC2SecurityGroup]
      PubliclyAccessible: false
      Tags: [{ Key: Name, Value: "iac-demo-rds" }]

Commands:

# Deploy via AWS CLI
aws cloudformation deploy --template-file vpc-ec2-rds.yaml --stack-name iac-demo-stack

CloudFormation uses declarative YAML. AWS manages the state and rollback, but syntax is verbose and less flexible for complex logic. Parameters and nested stacks can help, but there’s a steeper initial learning curve for anything nontrivial.

Syntax Comparison: HCL vs TypeScript vs YAML/JSON

The syntax you choose affects readability, maintainability, and reusability. Here’s how each stacks up for real teams:

AspectTerraform (HCL)Pulumi (TypeScript)CloudFormation (YAML)
Imperative LogicNo (limited via modules)Yes (full language)No (conditions, intrinsic functions only)
Code ReuseModules, variables, outputsFunctions, classes, packagesNested stacks, macros (advanced)
ReadabilityHigh for infra teamsHigh for dev teamsVerbose, but explicit
Drift Detectionterraform planpulumi previewChange sets, drift detection tool

Terraform is concise for infra-centric teams. Pulumi is ideal if you want to share IaC code with application developers or need advanced logic. CloudFormation is verbose but closest to AWS APIs and supports every new AWS feature first.

For advanced build workflows (e.g., Docker images), see advanced Docker multi-stage build strategies for production.

State Management and Change Tracking

How you store and track infrastructure state is critical for reliability and team safety. Each tool handles this differently:

Terraform State

  • Default: local terraform.tfstate file (not suitable for teams)
  • Best practice: remote backend (S3 + DynamoDB for locking, or HashiCorp’s Terraform Cloud)
  • Manual state locking, drift detection with terraform plan

Pulumi State

  • Default: local Pulumi.stack.yaml file
  • Best: Pulumi Service (SaaS) for RBAC, audit, and drift detection, or S3/GCS backend with encryption
  • Easy to script state migrations or stack splitting

CloudFormation State

  • Fully managed by AWS (no manual state files)
  • Built-in rollback on failure, change sets for previews
  • Detect drift via aws cloudformation detect-stack-drift

For compliance and security, always encrypt state files and restrict access. Never store secrets in plain text (use environment variables or vault integrations).

ToolState StorageLockingDrift DetectionSecrets Handling
TerraformLocal or remote backend (S3, Terraform Cloud)DynamoDB (S3 backend), built-in for Terraform Cloudterraform plan, terraform stateNot encrypted by default, use Vault or environment variables
PulumiLocal, Pulumi Service, S3, GCS, etc.Pulumi Service has built-in lockingpulumi preview, pulumi stackEncrypted with Pulumi secrets provider
CloudFormationManaged by AWSAutomaticChange sets, drift detection APIParameters must be handled securely or via Secrets Manager

For further reading on GitOps-based state management, see ArgoCD with GitOps as a complementary approach for Kubernetes deployments.

Learning Curve and Developer Experience

Your team’s existing skills and the complexity of your infrastructure should guide your IaC tool choice. Here’s what to expect:

  • Terraform: Widely adopted, tons of examples. Learning HCL is easy for infra engineers, but advanced patterns (modules, workspaces, remote state) take time. Great docs and community support.
  • Pulumi: If your team already works in TypeScript, Python, or Go, Pulumi feels natural. You can use real loops, conditionals, and test your infrastructure code like an application. However, debugging resource dependencies can be tricky if you’re used to declarative tools.
  • CloudFormation: Steepest learning curve for complex stacks. YAML/JSON is verbose, and intrinsic functions are non-intuitive. But for pure AWS shops, it’s the most integrated and supports new AWS features first. Advanced use (macros, custom resources) requires deep AWS knowledge.

In production, Terraform and Pulumi win on flexibility and multi-cloud support. CloudFormation is the right call for AWS-only, compliance-heavy environments where managed state and rollback are non-negotiable.

ToolOnboarding TimeBest ForGaps
TerraformLow (simple usage), Medium (modules, remote state)Infra/SRE teams, multi-cloudLimited imperative logic, secrets handling
PulumiMedium (if new to IaC), Low (if devs know TypeScript/Python)Dev teams, shared IaC/app logicSmaller community, less mature for edge AWS features
CloudFormationHigh (for complex stacks)Pure AWS, regulated orgsVerbosity, limited code reuse, slow for large stacks

Common Pitfalls and Pro Tips

Common Mistakes

  • Hardcoding secrets: Never put passwords or keys in your IaC files. Use environment variables, secrets managers, or encrypted state providers.
  • Not enabling state locking in Terraform: This leads to race conditions and broken deployments. Always use S3 + DynamoDB or Terraform Cloud for team use.
  • Ignoring resource dependencies: In Pulumi, you need to await or reference resource outputs explicitly. In CloudFormation, circular dependencies often cause stack failures.
  • Forgetting resource cleanup: Failing to destroy stacks (especially RDS or EC2 resources) can lead to unexpected AWS bills. Always clean up test environments.
  • Not using modules or reusable templates: Copy-pasting code multiplies maintenance pain. Use Terraform modules, Pulumi packages, or CloudFormation nested stacks.

Pro Tips

  • For production, always version and review your IaC in source control (Git branches, pull requests).
  • Automate linting and validation (e.g., terraform fmt, cfn-lint, pulumi lint) in CI/CD pipelines.
  • Set up automated drift detection jobs (e.g., nightly terraform plan or aws cloudformation detect-stack-drift).
  • When using Pulumi, take advantage of code reuse—write helper functions for common patterns (e.g., tagging, logging, monitoring).
  • For CloudFormation, use AWS SAM or CDK for more complex scenarios—the CDK lets you write IaC in real languages, then synthesize to CloudFormation YAML.

Conclusion & Next Steps

If you want concise, cross-cloud infrastructure code, Terraform is the workhorse. If your team prefers imperative code and wants to integrate infrastructure and application logic, Pulumi is a strong contender. For AWS-only shops, CloudFormation offers the deepest integration and managed state—but at the cost of verbosity and flexibility.

For Kubernetes-native workflows, check out GitOps with ArgoCD. If you’re building and deploying Docker images as part of your infrastructure, see advanced Docker multi-stage builds for production best practices.

Next step: Pick a tool, deploy a real environment, and automate your infrastructure. Your future self—and your team—will thank you.

For official documentation, see:

Start Sharing and Storing Files for Free

You can also get your own Unlimited Cloud Storage on our pay as you go product.
Other cool features include: up to 100GB size for each file.
Speed all over the world. Reliability with 3 copies of every file you upload. Snapshot for point in time recovery.
Collaborate with web office and send files to colleagues everywhere; in China & APAC, USA, Europe...
Tear prices for costs saving and more much more...
Create a Free Account Products Pricing Page