Terraform vs CloudFormation 2024: Complete Infrastructure as Code Comparison Guide

Comprehensive comparison of Terraform and AWS CloudFormation with 6+ real code examples. Master state management, multi-cloud strategies, module patterns, CI/CD integration, drift detection, cost analysis, and migration strategies to choose the right IaC tool.

Introduction: The IaC Decision That Shapes Your Cloud Strategy

Infrastructure as Code (IaC) is no longer optional—it's fundamental to modern cloud operations. Yet choosing between Terraform and AWS CloudFormation remains one of the most impactful decisions teams make when adopting cloud infrastructure management. Both tools have evolved significantly since their inception, each offering distinct advantages for different organizational needs.

This comprehensive guide provides a deep dive into both tools, comparing them across architecture, syntax, state management, team workflows, cost implications, and migration strategies. Whether you're evaluating for a new project, migrating existing infrastructure, or establishing enterprise-wide standards, this guide will help you make the right choice.


Part 1: Understanding Infrastructure as Code Fundamentals

Why Infrastructure as Code Matters

Infrastructure as Code transforms how teams manage cloud resources by enabling:

  • Repeatability and Consistency: Define infrastructure once, deploy identically across environments
  • Version Control: Track infrastructure changes like application code with full audit trails
  • Team Collaboration: Enable multiple engineers to work on infrastructure safely through review processes
  • Disaster Recovery: Quickly rebuild entire environments from code after failures
  • Cost Control: Identify unused resources, prevent configuration drift, and optimize spending
  • Compliance Automation: Embed security policies and compliance checks into your infrastructure code

Without IaC, teams resort to manual configuration, leading to "snowflake servers," inconsistent deployments, and security gaps.

The Three Types of IaC Tools

Before comparing Terraform and CloudFormation, understand the landscape:

  1. Declarative IaC (What should exist): You define the desired end state; the tool figures out how to achieve it. Both Terraform and CloudFormation are primarily declarative.

  2. Imperative IaC (How to create it): You script the exact steps to create infrastructure. Examples: Ansible, custom shell scripts.

  3. Templated IaC (Structured templates): Mix declarative and imperative approaches. CloudFormation uses JSON/YAML templates with intrinsic functions.


Part 2: AWS CloudFormation Deep Dive

CloudFormation Architecture and Core Concepts

AWS CloudFormation is Amazon's managed Infrastructure as Code service, launched in 2011. It treats infrastructure as templates that deploy as logical units called "stacks."

Key Architecture Components:

  • Templates: JSON or YAML files describing AWS resources and their properties
  • Stacks: Logical units of resources deployed from a single template
  • Stack Policies: Rules governing what changes are allowed to stack resources
  • Change Sets: Preview infrastructure changes before applying them
  • Drift Detection: Identify manual changes that deviate from the template definition

Native State Management:

CloudFormation manages state internally within AWS. There's no separate state file to version or secure—a significant advantage over Terraform for teams uncomfortable managing state files.

CloudFormation Syntax: YAML and JSON

CloudFormation templates have four main sections:

AWSTemplateFormatVersion: '2010-09-09'
Description: Complete example of CloudFormation template structure

Metadata:
  AWS::CloudFormation::Init:
    config: {}

Parameters:
  EnvironmentName:
    Type: String
    Default: production

Mappings:
  RegionMap:
    us-east-1:
      AMI: ami-12345678

Conditions:
  IsProduction: !Equals [!Ref EnvironmentName, 'production']

Resources:
  MyResource:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: my-bucket

Outputs:
  BucketName:
    Value: !Ref MyResource
    Export:
      Name: MyBucketName

Part 3: HashiCorp Terraform Deep Dive

Terraform Architecture and Core Concepts

Terraform, released in 2014, pioneered multi-cloud infrastructure provisioning with a provider-agnostic approach. It separates configuration from state and operations.

Key Architecture Components:

  • Configuration Files: HCL (HashiCorp Configuration Language) files describing desired infrastructure
  • State File: JSON file tracking your actual infrastructure for comparison with configuration
  • Providers: Plugins that interface with cloud platforms (AWS, Azure, Google Cloud, etc.)
  • Modules: Reusable packages of Terraform configurations
  • Workspaces: Named state file variants for managing multiple environments

Explicit State Management:

Terraform maintains a state file (local or remote) that maps your configuration to actual resources. This enables accurate change planning but requires careful state management practices.

Terraform Syntax: HashiCorp Configuration Language

HCL provides readable, JSON-compatible syntax optimized for infrastructure:

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

provider "aws" {
  region = var.aws_region
}

variable "environment" {
  type        = string
  default     = "production"
  description = "Environment name"
}

locals {
  common_tags = {
    Environment = var.environment
    ManagedBy   = "Terraform"
  }
}

resource "aws_s3_bucket" "example" {
  bucket = "my-bucket-${var.environment}"
  tags   = local.common_tags
}

output "bucket_name" {
  value       = aws_s3_bucket.example.id
  description = "Name of the S3 bucket"
}

Part 4: Side-by-Side Code Examples (8 Real-World Scenarios)

Example 1: Simple S3 Bucket with Versioning

CloudFormation (YAML):

Resources:
  ApplicationBucket:
    Type: AWS::S3::Bucket
    Properties:
      BucketName: my-app-bucket-prod
      VersioningConfiguration:
        Status: Enabled
      BucketEncryption:
        ServerSideEncryptionConfiguration:
          - ServerSideEncryptionByDefault:
              SSEAlgorithm: AES256
      PublicAccessBlockConfiguration:
        BlockPublicAcls: true
        BlockPublicPolicy: true
        IgnorePublicAcls: true
        RestrictPublicBuckets: true

Outputs:
  BucketName:
    Value: !Ref ApplicationBucket
    Export:
      Name: AppBucketName

Terraform (HCL):

resource "aws_s3_bucket" "application" {
  bucket = "my-app-bucket-prod"

  tags = {
    Name        = "Application Bucket"
    Environment = "production"
  }
}

resource "aws_s3_bucket_versioning" "application" {
  bucket = aws_s3_bucket.application.id

  versioning_configuration {
    status = "Enabled"
  }
}

resource "aws_s3_bucket_server_side_encryption_configuration" "application" {
  bucket = aws_s3_bucket.application.id

  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm = "AES256"
    }
  }
}

resource "aws_s3_bucket_public_access_block" "application" {
  bucket = aws_s3_bucket.application.id

  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

output "bucket_name" {
  value       = aws_s3_bucket.application.id
  description = "Name of the application S3 bucket"
}

Key Differences:

  • CloudFormation uses intrinsic functions and YAML structure; Terraform separates concerns into multiple resources
  • Terraform requires explicit resource creation for versioning and encryption; CloudFormation nests them in properties
  • Terraform references are explicit (aws_s3_bucket.application.id); CloudFormation uses intrinsic functions (!Ref)

Example 2: VPC with Subnets and NAT Gateway

CloudFormation (YAML):

Resources:
  ProductionVPC:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: 10.0.0.0/16
      EnableDnsHostnames: true
      EnableDnsSupport: true

  PublicSubnet:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref ProductionVPC
      CidrBlock: 10.0.1.0/24
      AvailabilityZone: !Select [0, !GetAZs '']
      MapPublicIpOnLaunch: true

  PrivateSubnet:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref ProductionVPC
      CidrBlock: 10.0.2.0/24
      AvailabilityZone: !Select [0, !GetAZs '']

  InternetGateway:
    Type: AWS::EC2::InternetGateway

  AttachIGW:
    Type: AWS::EC2::VPCGatewayAttachment
    Properties:
      VpcId: !Ref ProductionVPC
      InternetGatewayId: !Ref InternetGateway

  EIP:
    Type: AWS::EC2::EIP
    DependsOn: AttachIGW
    Properties:
      Domain: vpc

  NATGateway:
    Type: AWS::EC2::NatGateway
    Properties:
      AllocationId: !GetAtt EIP.AllocationId
      SubnetId: !Ref PublicSubnet

  PublicRouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref ProductionVPC

  PublicRoute:
    Type: AWS::EC2::Route
    DependsOn: AttachIGW
    Properties:
      RouteTableId: !Ref PublicRouteTable
      DestinationCidrBlock: 0.0.0.0/0
      GatewayId: !Ref InternetGateway

  AssociatePublicSubnet:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PublicSubnet
      RouteTableId: !Ref PublicRouteTable

  PrivateRouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref ProductionVPC

  PrivateRoute:
    Type: AWS::EC2::Route
    Properties:
      RouteTableId: !Ref PrivateRouteTable
      DestinationCidrBlock: 0.0.0.0/0
      NatGatewayId: !Ref NATGateway

  AssociatePrivateSubnet:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PrivateSubnet
      RouteTableId: !Ref PrivateRouteTable

Terraform (HCL):

data "aws_availability_zones" "available" {
  state = "available"
}

resource "aws_vpc" "production" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_hostnames = true
  enable_dns_support   = true

  tags = {
    Name = "production-vpc"
  }
}

resource "aws_subnet" "public" {
  vpc_id            = aws_vpc.production.id
  cidr_block        = "10.0.1.0/24"
  availability_zone = data.aws_availability_zones.available.names[0]

  tags = {
    Name = "public-subnet"
  }
}

resource "aws_subnet" "private" {
  vpc_id            = aws_vpc.production.id
  cidr_block        = "10.0.2.0/24"
  availability_zone = data.aws_availability_zones.available.names[0]

  tags = {
    Name = "private-subnet"
  }
}

resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.production.id

  tags = {
    Name = "main-igw"
  }
}

resource "aws_eip" "nat" {
  domain = "vpc"

  tags = {
    Name = "nat-eip"
  }

  depends_on = [aws_internet_gateway.main]
}

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

  tags = {
    Name = "main-nat"
  }

  depends_on = [aws_internet_gateway.main]
}

resource "aws_route_table" "public" {
  vpc_id = aws_vpc.production.id

  route {
    cidr_block      = "0.0.0.0/0"
    gateway_id      = aws_internet_gateway.main.id
  }

  tags = {
    Name = "public-rt"
  }
}

resource "aws_route_table_association" "public" {
  subnet_id      = aws_subnet.public.id
  route_table_id = aws_route_table.public.id
}

resource "aws_route_table" "private" {
  vpc_id = aws_vpc.production.id

  route {
    cidr_block     = "0.0.0.0/0"
    nat_gateway_id = aws_nat_gateway.main.id
  }

  tags = {
    Name = "private-rt"
  }
}

resource "aws_route_table_association" "private" {
  subnet_id      = aws_subnet.private.id
  route_table_id = aws_route_table.private.id
}

Key Differences:

  • CloudFormation uses !Select and !GetAZs intrinsic functions; Terraform uses data sources and interpolation
  • CloudFormation requires explicit dependency management with DependsOn; Terraform infers dependencies automatically
  • Terraform's approach is more modular, separating each resource concern; CloudFormation nests related configuration

Example 3: EC2 Instance with IAM Role

CloudFormation (YAML):

Resources:
  EC2Role:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: ec2.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore

  EC2InstanceProfile:
    Type: AWS::IAM::InstanceProfile
    Properties:
      Path: /
      Roles:
        - !Ref EC2Role

  EC2Instance:
    Type: AWS::EC2::Instance
    Properties:
      ImageId: ami-0c55b159cbfafe1f0
      InstanceType: t3.medium
      IamInstanceProfile: !Ref EC2InstanceProfile
      SubnetId: !Ref PrivateSubnet
      MetadataOptions:
        HttpTokens: required
        HttpPutResponseHopLimit: 1

      Tags:
        - Key: Name
          Value: ApplicationServer

Terraform (HCL):

resource "aws_iam_role" "ec2_role" {
  name = "ec2-instance-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRole"
        Effect = "Allow"
        Principal = {
          Service = "ec2.amazonaws.com"
        }
      }
    ]
  })
}

resource "aws_iam_role_policy_attachment" "ssm_policy" {
  policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
  role       = aws_iam_role.ec2_role.name
}

resource "aws_iam_instance_profile" "ec2_profile" {
  role = aws_iam_role.ec2_role.name
}

resource "aws_instance" "application" {
  ami                  = "ami-0c55b159cbfafe1f0"
  instance_type        = "t3.medium"
  subnet_id            = aws_subnet.private.id
  iam_instance_profile = aws_iam_instance_profile.ec2_profile.name

  metadata_options {
    http_tokens                 = "required"
    http_put_response_hop_limit = 1
    http_endpoint               = "enabled"
  }

  tags = {
    Name = "ApplicationServer"
  }
}

Example 4: RDS PostgreSQL Database

CloudFormation (YAML):

Resources:
  DBSubnetGroup:
    Type: AWS::RDS::DBSubnetGroup
    Properties:
      DBSubnetGroupDescription: Subnet group for RDS
      SubnetIds:
        - !Ref PrivateSubnet1
        - !Ref PrivateSubnet2

  RDSSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: Security group for RDS
      VpcId: !Ref VPC
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: 5432
          ToPort: 5432
          CidrIp: 10.0.0.0/16

  RDSDatabase:
    Type: AWS::RDS::DBInstance
    DeletionPolicy: Snapshot
    Properties:
      DBInstanceIdentifier: production-db
      DBInstanceClass: db.t3.small
      Engine: postgres
      EngineVersion: '15.3'
      MasterUsername: postgres
      MasterUserPassword: !Sub 'arn:aws:secretsmanager:us-east-1:123456789012:secret:db-password'
      AllocatedStorage: '100'
      StorageType: gp3
      StorageEncrypted: true
      MultiAZ: true
      DBSubnetGroupName: !Ref DBSubnetGroup
      VPCSecurityGroups:
        - !Ref RDSSecurityGroup
      BackupRetentionPeriod: 30
      PreferredBackupWindow: 03:00-04:00
      PreferredMaintenanceWindow: sun:04:00-sun:05:00
      EnableCloudwatchLogsExports:
        - postgresql

Outputs:
  DBEndpoint:
    Value: !GetAtt RDSDatabase.Endpoint.Address

Terraform (HCL):

resource "aws_db_subnet_group" "main" {
  name       = "main-db-subnet-group"
  subnet_ids = [aws_subnet.private1.id, aws_subnet.private2.id]
}

resource "aws_security_group" "rds" {
  name        = "rds-security-group"
  vpc_id      = aws_vpc.production.id
  description = "Security group for RDS"

  ingress {
    from_port   = 5432
    to_port     = 5432
    protocol    = "tcp"
    cidr_blocks = ["10.0.0.0/16"]
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

resource "aws_rds_cluster" "main" {
  cluster_identifier      = "production-db-cluster"
  engine                  = "aurora-postgresql"
  engine_version          = "15.3"
  database_name           = "appdb"
  master_username         = "postgres"
  master_password         = var.db_password
  db_subnet_group_name    = aws_db_subnet_group.main.name
  vpc_security_group_ids  = [aws_security_group.rds.id]
  storage_encrypted       = true
  backup_retention_period = 30
  preferred_backup_window = "03:00-04:00"
  enabled_cloudwatch_logs_exports = ["postgresql"]
  skip_final_snapshot     = false
  final_snapshot_identifier = "production-db-final-snapshot"
}

Example 5: Lambda Function with Environment Variables

CloudFormation (YAML):

Resources:
  LambdaRole:
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              Service: lambda.amazonaws.com
            Action: sts:AssumeRole
      ManagedPolicyArns:
        - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole

  LambdaFunction:
    Type: AWS::Lambda::Function
    Properties:
      FunctionName: process-events
      Runtime: python3.11
      Role: !GetAtt LambdaRole.Arn
      Handler: index.lambda_handler
      Timeout: 60
      MemorySize: 256
      Environment:
        Variables:
          ENVIRONMENT: production
          LOG_LEVEL: INFO
          API_ENDPOINT: https://api.example.com
      Code:
        ZipFile: |
          import json
          def lambda_handler(event, context):
              return {
                  'statusCode': 200,
                  'body': json.dumps('Hello from Lambda!')
              }

Outputs:
  FunctionArn:
    Value: !GetAtt LambdaFunction.Arn

Terraform (HCL):

resource "aws_iam_role" "lambda_role" {
  name = "lambda-execution-role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Action = "sts:AssumeRole"
        Effect = "Allow"
        Principal = {
          Service = "lambda.amazonaws.com"
        }
      }
    ]
  })
}

resource "aws_iam_role_policy_attachment" "lambda_basic" {
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
  role       = aws_iam_role.lambda_role.name
}

resource "aws_lambda_function" "process_events" {
  filename      = "lambda_function.zip"
  function_name = "process-events"
  role          = aws_iam_role.lambda_role.arn
  handler       = "index.lambda_handler"
  timeout       = 60
  memory_size   = 256
  runtime       = "python3.11"

  environment {
    variables = {
      ENVIRONMENT  = "production"
      LOG_LEVEL    = "INFO"
      API_ENDPOINT = "https://api.example.com"
    }
  }
}

output "function_arn" {
  value = aws_lambda_function.process_events.arn
}

Example 6: IAM User with Access Keys and Policies

CloudFormation (YAML):

Resources:
  ApplicationUser:
    Type: AWS::IAM::User
    Properties:
      UserName: app-deploy-user
      Policies:
        - PolicyName: S3DeployAccess
          PolicyDocument:
            Version: '2012-10-17'
            Statement:
              - Effect: Allow
                Action:
                  - s3:GetObject
                  - s3:PutObject
                  - s3:DeleteObject
                Resource: arn:aws:s3:::deployment-bucket/*
              - Effect: Allow
                Action:
                  - s3:ListBucket
                Resource: arn:aws:s3:::deployment-bucket

  UserAccessKey:
    Type: AWS::IAM::AccessKey
    Properties:
      UserName: !Ref ApplicationUser

Outputs:
  AccessKeyId:
    Value: !Ref UserAccessKey
  SecretAccessKey:
    Value: !GetAtt UserAccessKey.SecretAccessKey

Terraform (HCL):

resource "aws_iam_user" "app_deploy" {
  name = "app-deploy-user"
}

resource "aws_iam_user_policy" "s3_deploy_access" {
  name = "s3-deploy-access"
  user = aws_iam_user.app_deploy.name

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = [
          "s3:GetObject",
          "s3:PutObject",
          "s3:DeleteObject"
        ]
        Resource = "arn:aws:s3:::deployment-bucket/*"
      },
      {
        Effect = "Allow"
        Action = [
          "s3:ListBucket"
        ]
        Resource = "arn:aws:s3:::deployment-bucket"
      }
    ]
  })
}

resource "aws_iam_access_key" "app_deploy" {
  user = aws_iam_user.app_deploy.name
}

output "access_key_id" {
  value = aws_iam_access_key.app_deploy.id
}

output "secret_access_key" {
  value     = aws_iam_access_key.app_deploy.secret
  sensitive = true
}

Example 7: Auto Scaling Group with Launch Template

CloudFormation (YAML):

Resources:
  LaunchTemplate:
    Type: AWS::EC2::LaunchTemplate
    Properties:
      LaunchTemplateName: app-launch-template
      LaunchTemplateData:
        ImageId: ami-0c55b159cbfafe1f0
        InstanceType: t3.small
        KeyName: my-key-pair
        UserData: !Base64 |
          #!/bin/bash
          yum update -y
          yum install -y amazon-cloudwatch-agent
        TagSpecifications:
          - ResourceType: instance
            Tags:
              - Key: Name
                Value: AppServer

  AutoScalingGroup:
    Type: AWS::AutoScaling::AutoScalingGroup
    Properties:
      AutoScalingGroupName: app-asg
      VPCZoneIdentifier:
        - !Ref PrivateSubnet1
        - !Ref PrivateSubnet2
      LaunchTemplate:
        LaunchTemplateId: !Ref LaunchTemplate
        Version: !GetAtt LaunchTemplate.LatestVersionNumber
      MinSize: 2
      MaxSize: 10
      DesiredCapacity: 3
      HealthCheckType: ELB
      HealthCheckGracePeriod: 300
      Tags:
        - Key: Name
          Value: AppAutoScaled
          PropagateAtLaunch: true

  ScaleUpPolicy:
    Type: AWS::AutoScaling::ScalingPolicy
    Properties:
      AutoScalingGroupName: !Ref AutoScalingGroup
      PolicyType: TargetTrackingScaling
      TargetTrackingConfiguration:
        PredefinedMetricSpecification:
          PredefinedMetricType: ASGAverageCPUUtilization
        TargetValue: 70.0

Terraform (HCL):

resource "aws_launch_template" "app" {
  name_prefix = "app-launch-"
  image_id    = "ami-0c55b159cbfafe1f0"
  instance_type = "t3.small"
  key_name    = "my-key-pair"

  user_data = base64encode(<<-EOF
    #!/bin/bash
    yum update -y
    yum install -y amazon-cloudwatch-agent
  EOF
  )

  tag_specifications {
    resource_type = "instance"
    tags = {
      Name = "AppServer"
    }
  }
}

resource "aws_autoscaling_group" "app" {
  name                = "app-asg"
  vpc_zone_identifier = [aws_subnet.private1.id, aws_subnet.private2.id]
  
  launch_template {
    id      = aws_launch_template.app.id
    version = "$Latest"
  }

  min_size          = 2
  max_size          = 10
  desired_capacity  = 3
  health_check_type = "ELB"
  health_check_grace_period = 300

  tag {
    key                 = "Name"
    value               = "AppAutoScaled"
    propagate_at_launch = true
  }
}

resource "aws_autoscaling_policy" "scale_up" {
  name                   = "app-scale-up"
  scaling_adjustment     = 1
  adjustment_type        = "ChangeInCapacity"
  cooldown               = 300
  autoscaling_group_name = aws_autoscaling_group.app.name
}

resource "aws_cloudwatch_metric_alarm" "cpu_high" {
  alarm_name          = "app-cpu-high"
  comparison_operator = "GreaterThanThreshold"
  evaluation_periods  = 2
  metric_name         = "CPUUtilization"
  namespace           = "AWS/EC2"
  period              = 300
  statistic           = "Average"
  threshold           = 70
  alarm_actions       = [aws_autoscaling_policy.scale_up.arn]
}

Example 8: DynamoDB Table with Global Secondary Index

CloudFormation (YAML):

Resources:
  ApplicationTable:
    Type: AWS::DynamoDB::Table
    Properties:
      TableName: application-data
      BillingMode: PAY_PER_REQUEST
      AttributeDefinitions:
        - AttributeName: PK
          AttributeType: S
        - AttributeName: SK
          AttributeType: S
        - AttributeName: GSI1PK
          AttributeType: S
      KeySchema:
        - AttributeName: PK
          KeyType: HASH
        - AttributeName: SK
          KeyType: RANGE
      GlobalSecondaryIndexes:
        - IndexName: GSI1
          Keys:
            - AttributeName: GSI1PK
              KeyType: HASH
            - AttributeName: SK
              KeyType: RANGE
          Projection:
            ProjectionType: ALL
      StreamSpecification:
        StreamViewType: NEW_AND_OLD_IMAGES
      Tags:
        - Key: Environment
          Value: production

Outputs:
  TableArn:
    Value: !GetAtt ApplicationTable.Arn

Terraform (HCL):

resource "aws_dynamodb_table" "application" {
  name           = "application-data"
  billing_mode   = "PAY_PER_REQUEST"
  hash_key       = "PK"
  range_key      = "SK"

  attribute {
    name = "PK"
    type = "S"
  }

  attribute {
    name = "SK"
    type = "S"
  }

  attribute {
    name = "GSI1PK"
    type = "S"
  }

  global_secondary_index {
    name            = "GSI1"
    hash_key        = "GSI1PK"
    range_key       = "SK"
    projection_type = "ALL"
  }

  stream_specification {
    stream_view_type = "NEW_AND_OLD_IMAGES"
  }

  tags = {
    Environment = "production"
  }
}

output "table_arn" {
  value = aws_dynamodb_table.application.arn
}

Part 5: Advanced Comparisons

State Management: The Critical Difference

CloudFormation State:

  • Managed entirely by AWS
  • No state file to manage or protect
  • State consistency guaranteed by AWS
  • Drift detection via describe-stack-resource-drifts API

Terraform State:

  • Stored locally by default (dangerous in teams)
  • Must be stored remotely (S3, Terraform Cloud, etc.)
  • State locking prevents concurrent modifications
  • Team collaboration requires robust state management

State management is arguably Terraform's biggest operational burden, but also its greatest strength for complex multi-cloud scenarios.

Modules vs Nested Stacks

CloudFormation Nested Stacks:

Resources:
  NetworkStack:
    Type: AWS::CloudFormation::Stack
    Properties:
      TemplateURL: https://s3.amazonaws.com/bucket/network.yaml
      Parameters:
        VPCCidr: 10.0.0.0/16

Nested stacks create dependencies between templates but remain single CloudFormation stacks at the top level.

Terraform Modules:

module "network" {
  source = "./modules/network"
  
  vpc_cidr = "10.0.0.0/16"
  environment = var.environment
}

Modules are more flexible—they can come from local paths, Git repositories, Terraform Registry, or HTTP sources. They're also more composable.

Multi-Cloud and Multi-Region

Terraform Excels at:

  • Single HCL codebase managing AWS, Azure, GCP simultaneously
  • Consistent provider interface across clouds
  • Switching providers with minimal code changes

CloudFormation:

  • AWS-only, requiring separate tools for other clouds
  • Excellent for AWS-specific features (StackSets for multi-region)
  • Limited by AWS service availability

For true multi-cloud initiatives, Terraform is significantly easier to manage.

CI/CD Pipeline Integration

CloudFormation Integration:

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::CodePipeline-CloudFormation
Description: Deploying via CodePipeline

Parameters:
  ChangeSetType:
    Type: String
    Default: CREATE

CloudFormation integrates directly with AWS CodePipeline, CodeDeploy, and CodeBuild.

Terraform Integration:

# Typical Terraform CI/CD workflow

# Plan stage
terraform plan -out=tfplan

# Review/Approval
# Manual approval in CI/CD system

# Apply stage
terraform apply tfplan

Terraform works with any CI/CD system (GitLab CI, GitHub Actions, Jenkins, CircleCI, etc.) but requires more manual workflow configuration.

Testing and Validation

CloudFormation Validation:

# Syntax validation
aws cloudformation validate-template --template-body file://template.yaml

# Best practices analysis
cfn-lint template.yaml

Terraform Testing:

# Syntax and validation
terraform validate

# Format checking
terraform fmt -check

# Complex testing with Terratest (Go)
go test -v

Terraform enables more sophisticated testing through frameworks like Terratest, enabling pre-deployment validation of infrastructure behavior.

Drift Detection and Remediation

CloudFormation Drift Detection:

# Detect drift
aws cloudformation detect-stack-drift --stack-name my-stack

# View drift details
aws cloudformation describe-stack-resource-drifts --stack-name my-stack

CloudFormation provides native drift detection showing exactly what manual changes were made.

Terraform Drift Detection:

# Refresh state
terraform refresh

# Plan shows drift
terraform plan

# Remediate by applying
terraform apply

Terraform's terraform plan command shows drift but requires re-applying to fix it, which can be dangerous in production.


Part 6: Migration Strategies

Migrating from CloudFormation to Terraform

Step 1: Inventory Your Stacks

aws cloudformation list-stacks --query 'StackSummaries[?StackStatus!=DELETE_COMPLETE].StackName' --output table

Step 2: Convert Templates

Use tools like cf2tf or manual conversion:

cf2tf convert --template my-template.yaml > main.tf

Step 3: Import Resources

# Write Terraform resource blocks
resource "aws_s3_bucket" "example" {
  # Properties will be filled in by import
}

# Import existing resource
terraform import aws_s3_bucket.example my-bucket-name

Step 4: Incremental Migration

Migrate stacks one-at-a-time, testing thoroughly before moving to the next.

Migrating from Terraform to CloudFormation

This is significantly more challenging due to CloudFormation's limitations. Generally:

  1. Keep Terraform managing multi-cloud resources
  2. Use CloudFormation only for AWS-specific infrastructure
  3. Integrate both tools in your CI/CD pipeline

Part 7: Cost Considerations

CloudFormation Costs:

  • Completely free for use
  • Pay only for AWS resources deployed
  • No additional operational overhead

Terraform Costs:

  • Terraform Core: Free
  • Terraform Cloud/Enterprise: $20-70/month per user for remote state, team management
  • Self-hosted state backend (S3): ~$1-5/month for typical workloads

For small teams, Terraform state management costs are minimal. For enterprise deployments, Terraform Cloud/Enterprise adds significant expense.


Part 8: When to Choose Each Tool

Choose CloudFormation When:

  1. AWS-Only Environment: Your organization uses only AWS
  2. Native AWS Features Matter: You need deep AWS service integration
  3. Managed State: You want AWS handling state completely
  4. Change Sets: You require preview capabilities before applying changes
  5. Control Tower: You're using AWS Control Tower for governance
  6. Team Unfamiliar with Code: Team members prefer JSON/YAML over HCL
  7. Minimal Operational Overhead: Small teams wanting minimal infrastructure tooling

Choose Terraform When:

  1. Multi-Cloud Requirements: Managing AWS, Azure, and GCP simultaneously
  2. Provider Ecosystem: You need providers CloudFormation doesn't support
  3. Complex Module Reuse: Extensive code reusability across projects
  4. Existing Terraform Investment: Team already experienced with Terraform
  5. Advanced Testing: Requiring sophisticated pre-deployment validation
  6. Team Preference: Engineers prefer HCL syntax and Terraform workflows
  7. Long-term Flexibility: Anticipating multi-cloud needs in the future

Choose Both:

Many organizations successfully use:

  • CloudFormation for core AWS infrastructure (VPCs, security groups, IAM)
  • Terraform for application-level resources and multi-cloud integrations
  • Complementary workflows in CI/CD pipelines

Working with Warqline

We are a cloud engineering consultancy and an official AWS and Google Cloud partner. If you are running this in production and want a second pair of eyes, we scope work in a free 45-minute technical call: you describe what you are running and what worries you, and we tell you what we would look at first.

Talk to an engineer

Conclusion: Making Your IaC Tool Decision

Both Terraform and CloudFormation are mature, production-ready Infrastructure as Code tools. The best choice depends on your specific organizational context:

  • Terraform is superior for multi-cloud scenarios, team flexibility, and organizations already invested in the Terraform ecosystem
  • CloudFormation is superior for AWS-only organizations that want minimal operational overhead and AWS-integrated workflows

Consider:

  1. Your current cloud strategy (AWS-only vs. multi-cloud)
  2. Team expertise and preferences
  3. Organizational standardization requirements
  4. Long-term infrastructure vision

Whatever you choose, implement strong version control, testing, and approval processes. The tool matters less than the discipline of treating infrastructure as code with the same rigor as application code.

For organizations uncertain about their choice, remember that modern CI/CD pipelines can integrate both tools. You're not locked into a single decision—you can evolve your tooling as your infrastructure needs mature.

Start your infrastructure optimization journey with Warqline, which assesses and recommends improvements regardless of which IaC tool you use.