DEV Community

Timevolt
Timevolt

Posted on

Infrastructure as Code: Terraform and CloudFormation – The Fellowship of the Cloud

The Quest Begins (The "Why")

I still remember the first time I had to spin up a three‑tier web app for a demo. I opened the AWS Console, clicked “Launch Instance”, picked an AMI, chose a security group, tagged everything, then repeated the same steps for the DB layer, the load balancer, and the IAM roles. Twenty minutes later I had a working environment… and a sinking feeling that I’d just duplicated the same clicks for the next sprint. A few days later, a teammate asked me to tear it down for cost reasons, and I realized I had no record of exactly what I’d created. I spent an hour hunting down stray resources, deleting orphaned Elastic IPs, and praying I hadn’t left a back‑door open. That night I thought, “There has to be a better way.”

The Revelation (The Insight)

The better way is Infrastructure as Code (IaC): treat your infrastructure like application source code. Instead of clicking consoles or running ad‑hoc CLI commands, you write declarative files that describe the desired state. A tool then figures out how to reach that state, creates missing pieces, updates what changed, and leaves everything else alone.

Two heavyweight contenders in the AWS world are Terraform and CloudFormation. CloudFormation is native to AWS—its templates are JSON or YAML that AWS reads directly. Terraform, from HashiCorp, is cloud‑agnostic; you write HashiCorp Configuration Language (HCL) and it talks to AWS (or Azure, GCP, etc.) through providers. Both give you version control, reproducibility, and the ability to review changes with pull requests—basically turning ops into a software engineering practice.

Wielding the Power (Code & Examples)

The Struggle: Manual / Click‑Ops

Here’s what the “before” looked like for a simple web server with an associated security group:

# 1️⃣ Create security group
aws ec2 create-security-group \
    --group-id web-sg \
    --description "Allow HTTP inbound" \
    --vpc-id vpc-0a1b2c3d4e5f6g7h8

# 2️⃣ Authorize inbound HTTP
aws ec2 authorize-security-group-ingress \
    --group-id web-sg \
    --protocol tcp \
    --port 80 \
    --cidr 0.0.0.0/0

# 3️⃣ Launch EC2 instance
aws ec2 run-instances \
    --image-id ami-0abcdef1234567890 \
    --instance-type t3.micro \
    --security-group-ids web-sg \
    --subnet-id subnet-11aa22bb \
    --tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=web-server}]'
Enter fullscreen mode Exit fullscreen mode

If you needed to change the instance type or add a new rule, you’d have to remember each command, edit the right line, and hope you didn’t miss a step. Worse, if you ran the script twice you’d get errors about duplicate resources unless you added extra idempotency checks yourself.

The Victory: Terraform

With Terraform the same intent lives in a few tidy files. First, versions.tf locks the provider version (a common trap—forgetting this can lead to surprising upgrades):

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

provider "aws" {
  region = "us-east-1"
}
Enter fullscreen mode Exit fullscreen mode

Next, main.tf declares the resources. Notice how we reference the security group directly—no need to copy IDs around:

resource "aws_security_group" "web_sg" {
  name        = "web-sg"
  description = "Allow HTTP inbound"
  vpc_id      = data.aws_vpc.selected.id

  ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = { Name = "web-sg" }
}

data "aws_vpc" "selected" {
  id = var.vpc_id
}

resource "aws_instance" "web" {
  ami           = var.ami_id
  instance_type = var.instance_type
  subnet_id     = var.subnet_id
  vpc_security_group_ids = [aws_security_group.web_sg.id]

  tags = {
    Name = "web-server"
  }
}
Enter fullscreen mode Exit fullscreen mode

Variables live in variables.tf (making the module reusable) and outputs in outputs.tf so you can export the instance IP or DNS name:

variable "vpc_id" {
  description = "ID of the VPC to deploy into"
  type        = string
}

variable "ami_id" {
  description = "EC2 AMI to use"
  type        = string
  default     = "ami-0abcdef1234567890"
}

variable "instance_type" {
  description = "EC2 instance type"
  type        = string
  default     = "t3.micro"
}

variable "subnet_id" {
  description = "Subnet for the instance"
  type        = string
}

output "web_instance_id" {
  value = aws_instance.web.id
}

output "web_public_ip" {
  value = aws_instance.web.public_ip
}
Enter fullscreen mode Exit fullscreen mode

Common traps to avoid

  • Hardcoding IDs – If you copy‑paste a subnet or AMI ID straight into the code, your module becomes unusable in other accounts or regions. Use variables or data sources (data.aws_ami, data.aws_subnet_ids).
  • Neglecting state – Terraform stores state locally by default (terraform.tfstate). If you commit that file to Git or let each teammate keep a separate copy, you’ll drift. Use a remote backend (S3 + DynamoDB lock) early.
  • Skipping version constraints – Without a provider version, a minor upgrade might change argument names and break your apply. Pin with ~> or explicit versions.

The Victory: CloudFormation

If you prefer staying inside AWS’s ecosystem, a CloudFormation template does the same job in YAML (or JSON). The equivalent looks like this:

AWSTemplateFormatVersion: '2010-09-09'
Description: Simple web server with security group

Parameters:
  VpcId:
    Type: String
    Description: ID of the VPC
  AmiId:
    Type: String
    Default: ami-0abcdef1234567890
    Description: EC2 AMI
  InstanceType:
    Type: String
    Default: t3.micro
    Description: EC2 instance type
  SubnetId:
    Type: String
    Description: Subnet for the instance

Resources:
  WebSecurityGroup:
    Type: AWS::EC2::SecurityGroup
    Properties:
      GroupDescription: Allow HTTP inbound
      VpcId: !Ref VpcId
      SecurityGroupIngress:
        - IpProtocol: tcp
          FromPort: 80
          ToPort: 80
          CidrIp: 0.0.0.0/0
      Tags:
        - Key: Name
          Value: web-sg

  WebInstance:
    Type: AWS::EC2::Instance
    Properties:
      ImageId: !Ref AmiId
      InstanceType: !Ref InstanceType
      SubnetId: !Ref SubnetId
      SecurityGroupIds:
        - !Ref WebSecurityGroup
      Tags:
        - Key: Name
          Value: web-server

Outputs:
  WebInstanceId:
    Description: ID of the EC2 instance
    Value: !Ref WebInstance
  WebPublicIp:
    Description: Public IP of the instance
    Value: !GetAtt WebInstance.PublicIp
Enter fullscreen mode Exit fullscreen mode

Typical pitfalls

  • Using Ref incorrectly – Forgetting that Ref on a security group returns its ID, but on a subnet returns the subnet ID, can cause confusing validation errors.
  • Overly large templates – Packing dozens of resources into a single file makes it hard to review. Break into nested stacks or use macros.
  • Ignoring Change Sets – Applying a template directly can surprise you with replacements. Always generate a Change Set first to see what will be created, updated, or destroyed.

Why This New Power Matters

Now that I’ve got my infrastructure in Git, I can:

  • Review changes – A pull request shows exactly what will be added, removed, or modified before a single API call hits AWS.
  • Reuse modules – The same Terraform module spins up identical dev, staging, and prod environments with just different variable files.
  • Roll back safely – If a deploy goes wrong, terraform destroy

Top comments (0)