DEV Community

Timevolt
Timevolt

Posted on

From ClickOps to IaC: Terraform & CloudFormation – A Journey Like Neo in The Matrix

The Quest Begins (The "Why")

Honestly, I used to spend Friday nights clicking through the AWS console, spinning up EC2 instances, attaching security groups, and hoping I didn’t forget to tag anything. It felt like playing Whac‑A‑Mole with a blindfold on—every time I thought I had the environment stable, a teammate would spin up a new resource in a different region, and suddenly our drift report looked like modern art.

One Monday morning, after a three‑hour outage caused by a missing IAM policy that someone had manually deleted (yes, really), I stared at the CloudTrail logs and thought: there has to be a better way. I wanted a single source of truth, something version‑controlled, repeatable, and—dare I say—fun to work with. That’s when I stumbled into the world of Infrastructure as Code (IaC).

The Revelation (The Insight)

The magic moment came when I realized IaC isn’t just about writing JSON or YAML; it’s about treating your infrastructure like application code. You get linting, code reviews, pull requests, and the ability to roll back a bad change with a simple git revert.

Two heavyweight contenders emerged for my AWS‑centric workflow: Terraform (cloud‑agnostic, HCL syntax) and CloudFormation (native AWS, JSON/YAML). Instead of picking a side, I decided to learn both and see where each shines. The insight? Use Terraform for the foundation—networking, IAM roles, and cross‑account resources—then let CloudFormation handle the application‑specific pieces that rely heavily on AWS‑only features like Lambda@Edge or Aurora Serverless v2.

Wielding the Power (Code & Examples)

The Struggle: ClickOps & Manual Scripts

Here’s a snippet of the Bash script I used to provision a VPC, an IGW, and a couple of subnets. Spoiler: it was fragile, hard to test, and impossible to peer review.

#!/bin/bash
# create-vpc.sh – the old way
VPC_ID=$(aws ec2 create-vpc --cidr-block 10.0.0.0/16 --query 'Vpc.VpcId' --output text)
aws ec2 modify-vpc-attribute --vpc-id $VPC_ID --enable-dns-support
aws ec2 modify-vpc-attribute --vpc-id $VPC_ID --enable-dns-hostnames

IGW_ID=$(aws ec2 create-internet-gateway --query 'InternetGateway.InternetGatewayId' --output text)
aws ec2 attach-internet-gateway --vpc-id $VPC_ID --internet-gateway-id $IGw_ID

# Public subnet
aws ec2 create-subnet --vpc-id $VPC_ID --cidr-block 10.0.1.0/24 --availability-zone us-east-1a \
    --query 'Subnet.SubnetId' --output text > /tmp/public-subnet-id
PUBLIC_SUBNET_ID=$(cat /tmp/public-subnet-id)
aws ec2 modify-subnet-attribute --subnet-id $PUBLIC_SUBNET_ID --map-public-ip-on-launch

# Private subnet
aws ec2 create-subnet --vpc-id $VPC_ID --cidr-block 10.0.2.0/24 --availability-zone us-east-1a \
    --query 'Subnet.SubnetId' --output text > /tmp/private-subnet-id
PRIVATE_SUBNET_ID=$(cat /tmp/private-subnet-id)

# Route table for public subnet
RT_ID=$(aws ec2 create-route-table --vpc-id $VPC_ID --query 'RouteTable.RouteTableId' --output text)
aws ec2 create-route --route-table-id $RT_ID --destination-cidr-block 0.0.0.0/0 --gateway-id $IGW_ID
aws ec2 associate-route-table --subnet-id $PUBLIC_SUBNET_ID --route-table-id $RT_ID
Enter fullscreen mode Exit fullscreen mode

Running this felt like defusing a bomb—one typo and you’d end up with a dangling IGW or a subnet without a route.

The Victory: Terraform for the Base Network

Now, the same architecture lives in a few lines of HCL. Terraform keeps state, lets me plan changes, and gives me a beautiful graph of dependencies.

# main.tf – VPC with public & private subnets
provider "aws" {
  region = "us-east-1"
}

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

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

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

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

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

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

resource "aws_subnet" "private" {
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.2.0/24"
  availability_zone = "us-east-1a"

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

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

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

resource "aws_route" "public_internet" {
  route_table_id         = aws_route_table.public.id
  destination_cidr_block = "0.0.0.0/0"
  gateway_id             = aws_internet_gateway.igw.id
}

resource "aws_route_table_association" "public_assoc" {
  subnet_id      = aws_subnet.public.id
  route_table_id = aws_route_table.public.id
}
Enter fullscreen mode Exit fullscreen mode

Why this feels like a win:

  • terraform plan shows me exactly what will change before I apply.
  • If I forget to attach the IGW, the plan fails fast—no more guessing.
  • The whole file lives in Git; a teammate can review it, suggest a tag, or add a new subnet with a simple pull request.

CloudFormation for Application‑Specific Goodies

When I needed to deploy a Lambda@Edge function that modifies CloudFront headers, CloudFormation’s native support for Lambda@Edge made life easier. Terraform can do it, but the CloudFormation syntax felt more straightforward for this AWS‑only feature.

# lambda-edge.template.yaml
AWSTemplateFormatVersion: '2010-09-09'
Description: Lambda@Edge to add security headers

Resources:
  EdgeLambdaFunction:
    Type: AWS::Lambda::Function
    Properties:
      Handler: index.handler
      Runtime: nodejs18.x
      Code:
        ZipFile: |
          exports.handler = (event, context, callback) => {
            const response = event.Records[0].cf.response;
            const headers = response.headers;
            headers['strict-transport-security'] = [{key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubdomains; preload'}];
            headers['content-security-policy'] = [{key: 'Content-Security-Policy', value: "default-src 'self';"}];
            callback(null, response);
          }
      Role: !GetAtt EdgeLambdaRole.Arn

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

  EdgeLambdaVersion:
    Type: AWS::Lambda::Version
    Properties:
      FunctionName: !Ref EdgeLambdaFunction
      Description: v1.0

  EdgeLambdaAlias:
    Type: AWS::Lambda::Alias
    Properties:
      FunctionName: !Ref EdgeLambdaFunction
      Name: LIVE
      FunctionVersion: !GetAtt EdgeLambdaVersion.Version
Enter fullscreen mode Exit fullscreen mode

I stack this template inside a larger CloudFormation pipeline that also creates the CloudFront distribution, attaches the Lambda@Edge, and sets up the required IAM permissions. The best part? I can version‑control this YAML alongside my Terraform modules, and a single CI pipeline runs terraform apply followed by aws cloudformation deploy.

Traps to Avoid (The “Treats” on the Quest)

  1. Hardcoding IDs – In early Terraform attempts I copied subnet IDs from the console into variables. Never. Let Terraform create and reference resources via attributes (aws_subnet.public.id). Hardcoding leads to drift and painful refactors.

  2. Missing Dependencies in CloudFormation – I once forgot to add a DependsOn attribute for the Lambda@Edge version, causing the CloudFront distribution to reference a $LATEST version that wasn’t published yet. The fix was simple: add DependsOn: EdgeLambdaVersion. Always verify that downstream resources wait for the ones they need.

Why This New Power Matters

Now my infrastructure feels less like a haunted house and more like a well‑orchestrated symphony. I can spin up a complete dev environment in under five minutes, destroy it just as fast, and know that every change is traceable. Code reviews catch missing tags, over‑permissive security groups, or stray resources before they ever hit production.

The real win? Confidence. When a production incident occurs, I no longer waste hours hunting for a manually‑changed NACL; I look at the Git history, see the offending commit, roll back, and breathe.

And the best part? The skills are transferable. The same Terraform modules that build a VPC for a web app can be reused for a data‑pipeline workload, and the CloudFormation snippets I wrote for Lambda@Edge can be adapted for any edge‑compute scenario.

Your Turn – Start Your Own Quest

Ready to trade the click‑fatigue for code‑driven clarity? Pick a small piece of your environment—maybe a single S3 bucket with a static website—and write a Terraform module for it. Then, try wrapping a Lambda function in a CloudFormation template. Share your repo, ask for feedback, and watch how quickly the whole team starts to trust the process.

What’s the first IaC spell you’ll cast? Drop a link to your repo in the comments—I can’t wait to see what you build! 🚀

Top comments (0)