DEV Community

Timevolt
Timevolt

Posted on

The Terraform Awakens: Infrastructure as Code Quest

The Quest Begins (The "Why")

Honestly, I was tired of playing “guess the state” every time I spun up a new environment. One day I clicked “Apply” in the AWS console, watched a handful of EC2 instances, S3 buckets, and IAM roles appear, and then realized I had no idea how to recreate that exact setup six months later when the team needed a staging copy. It felt like trying to rebuild the Death Star from memory after a single glance at the blueprints—frustrating, error‑prone, and definitely not the heroic saga I signed up for.

That moment was my “aha!”: I needed a repeatable, version‑controlled way to describe infrastructure. Enter Infrastructure as Code (IaC). I’d heard the buzz, but the real question was which tool to wield—Terraform or CloudFormation? Both promised declarative provisioning, but they spoke different dialects. I decided to embark on a quest to learn both, slay the configuration drift dragon, and come out with a reusable spellbook I could share with anyone on the team.

The Revelation (The Insight)

The breakthrough came when I stopped thinking of IaC as “just another config file” and started seeing it as a storytelling language. Every resource block is a character, every variable a plot twist, and the state file the ever‑growing script that remembers what happened in previous chapters.

When I wrote my first Terraform module, it felt like Neo realizing he could bend the spoon—suddenly the impossible became trivial. I could define a VPC, subnets, security groups, and an RDS instance in a few dozen lines, run terraform init, terraform plan, and watch the plan show exactly what would change before any resources touched the cloud. No more surprise “you created a public‑facing DB!” moments.

CloudFormation, on the other hand, felt like the loyal sidekick that already lives in the AWS universe. Its JSON/YAML templates are native to AWS, so there’s no extra provider to install, and drift detection is built‑in. The trade‑off? A bit more verbosity and a steeper learning curve for complex conditionals.

Both tools share the same core promise: code that describes your desired end state, and a engine that makes reality match it. Once I internalized that, the rest was just learning the syntax and avoiding a few classic traps.

Wielding the Power (Code & Examples)

The Struggle: Manual Click‑Ops (Before)

Imagine you need a simple web tier: an VPC, two public subnets, an Internet Gateway, and a security group that allows HTTP/80. Doing this via the console feels like assembling IKEA furniture without the instructions—possible, but you’ll probably end up with extra screws and a wobbly shelf.

The Victory: Terraform (After)

# main.tf
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

provider "aws" {
  region = var.region
}

variable "region" {
  description = "AWS region to deploy into"
  type        = string
  default     = "us-east-1"
}

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

resource "aws_internet_gateway" "gw" {
  vpc_id = aws_vpc.main.id
  tags = {
    Name = "quest-igw"
  }
}

resource "aws_subnet" "public" {
  count             = 2
  vpc_id            = aws_vpc.main.id
  cidr_block        = cidrsubnet(aws_vpc.main.cidr_block, 4, count.index)
  map_public_ip_on_launch = true
  tags = {
    Name = "quest-public-${count.index}"
  }
}

# Attach subnets to the IGW via a route table
resource "aws_route_table" "public" {
  vpc_id = aws_vpc.main.id
  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = aws_internet_gateway.gw.id
  }
  tags = {
    Name = "quest-rt-public"
  }
}

resource "aws_route_table_association" "public" {
  count          = 2
  subnet_id      = aws_subnet.public[count.index].id
  route_table_id = aws_route_table.public.id
}

resource "aws_security_group" "web" {
  name        = "allow-http"
  description = "Allow HTTP inbound"
  vpc_id      = aws_vpc.main.id

  ingress {
    from_port   = 80
    to_port     = 80
    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 = "quest-sg-web"
  }
}
Enter fullscreen mode Exit fullscreen mode

What changed?

  • Declarative clarity: I state what I want, not how to build it step‑by‑step.
  • Plan preview: terraform plan shows a diff before any API calls.
  • State tracking: After terraform apply, Terraform writes a terraform.tfstate file that remembers exactly what exists, making future updates safe.
  • Reusability: Wrap this in a module, pass in a different CIDR or region, and you have a repeatable VPC pattern.

Common Terraform Traps (The “Trap Doors”)

  1. Forgetting to lock provider versions – If you omit the required_providers block, a teammate might pull a newer provider that introduces breaking changes. Always pin versions.
  2. Hard‑coding secrets – Never embed AWS keys directly in .tf files. Use environment variables, AWS IAM roles for EC2/ECS, or tools like terraform.tfvars that are git‑ignored.

The Victory: CloudFormation (After)

Now let’s see the same architecture expressed as a CloudFormation template (YAML for readability).

AWSTemplateFormatVersion: '2010-09-09'
Description: >-
  Quest VPC with public subnets, IGW, route table, and web SG.

Parameters:
  Region:
    Type: String
    Default: us-east-1
    Description: AWS region for deployment

Resources:
  VPC:
    Type: AWS::EC2::VPC
    Properties:
      CidrBlock: 10.0.0.0/16
      Tags:
        - Key: Name
          Value: quest-vpc

  InternetGateway:
    Type: AWS::EC2::InternetGateway
    Properties:
      Tags:
        - Key: Name
          Value: quest-igw

  VPCGatewayAttachment:
    Type: AWS::EC2::VPCGatewayAttachment
    Properties:
      VpcId: !Ref VPC
      InternetGatewayId: !Ref InternetGateway

  PublicSubnetA:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref VPC
      CidrBlock: 10.0.0.0/20
      MapPublicIpOnLaunch: true
      AvailabilityZone: !Select [0, !GetAZs '' !Ref Region]
      Tags:
        - Key: Name
          Value: quest-public-a

  PublicSubnetB:
    Type: AWS::EC2::Subnet
    Properties:
      VpcId: !Ref VPC
      CidrBlock: 10.0.16.0/20
      MapPublicIpOnLaunch: true
      AvailabilityZone: !Select [1, !GetAZs '' !Ref Region]
      Tags:
        - Key: Name
          Value: quest-public-b

  PublicRouteTable:
    Type: AWS::EC2::RouteTable
    Properties:
      VpcId: !Ref VPC
      Tags:
        - Key: Name
          Value: quest-rt-public

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

  SubnetRouteTableAssociationA:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PublicSubnetA
      RouteTableId: !Ref PublicRouteTable

  SubnetRouteTableAssociationB:
    Type: AWS::EC2::SubnetRouteTableAssociation
    Properties:
      SubnetId: !Ref PublicSubnetB
      RouteTableId: !Ref PublicRouteTable

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

Outputs:
  VPCId:
    Description: ID of the VPC
    Value: !Ref VPC
  PublicSubnetIds:
    Description: List of public subnet IDs
    Value: !Join [",", [!Ref PublicSubnetA, !Ref PublicSubnetB]]
Enter fullscreen mode Exit fullscreen mode

What changed?

  • Native AWS integration: No extra provider; the template is understood directly by CloudFormation.
  • Built‑in drift detection: Run aws cloudformation detect-stack-drift and get a report of any manual changes.
  • Parameterization: The Parameters section lets you customize region or CIDR without editing the template.

Common CloudFormation Traps (The “Trap Doors”)

  1. Over‑using !Ref for complex conditions – CloudFormation’s condition functions (Fn::If, Fn::Equals) can get messy. Keep logic simple or move complex logic to a Lambda-backed custom resource.
  2. Missing DependsOn – If you create a route that depends on the IGW attachment, you must declare the dependency; otherwise, CloudFormation may try to create the route before the IGW is attached, leading to rollback failures.

Why This New Power Matters

Now that I can define infrastructure as code, my workflow feels like leveling up in an RPG. I spin up a dev environment with a single terraform apply or aws cloudformation create-stack, run my test suite, and then tear it all down with equal with a terraform destroy or aws cloudformation.`

The real win is confidence. Every change goes through version control, peer review, and automated planning. When a production incident happens, I can diff the current state against the last known good commit and pinpoint exactly what drifted. No more “who opened that security group?” mysteries.

And the best part? The skills transfer. Once you grasp the declarative mindset, switching between Terraform, CloudFormation, Pulumi, or even CDK becomes a matter of learning new syntax, not a new way of thinking.

Your Turn: Embark on Your Own Quest

Here’s a challenge: take a simple piece of infrastructure you usually click‑together in the console—a S3 bucket for static website hosting, a Lambda function with an API Gateway, or a modest EKS cluster—and write it once in Terraform and once in CloudFormation. Compare the plans, notice where each tool shines, and share your findings in the comments.

What’s the first resource you’ll codify? Drop your answer below, and let’s keep the quest going! 🚀

Top comments (0)