0x55aa
← Back to Blog

Terraform: Stop Clicking Around the AWS Console Like It's 2015 🏗ïļ

â€Ē11 min read

Terraform: Stop Clicking Around the AWS Console Like It's 2015 🏗ïļ

True story: I once spent 6 hours debugging why production was behaving differently from staging. Same app. Same Docker image. Different infrastructure — because a colleague had manually clicked "Enable auto-scaling" in the AWS console three weeks earlier and told nobody.

No ticket. No PR. No comment in Slack. Just vibes and a checkbox.

That was the day I became a Terraform evangelist. Loudly. Aggressively. At standup. ðŸ˜Ī

What Is Infrastructure as Code, Really? ðŸĪ”

Infrastructure as Code (IaC) means your servers, databases, load balancers, DNS records — everything — is described in code files that live in Git.

The old way (Console Cowboy ðŸĪ ):

1. Log into AWS console
2. Click around for 30 minutes
3. Forget half the settings
4. Staging and production diverge silently
5. 3 AM incident: "who changed the security group?"
6. Answer: nobody knows
7. You quit

The Terraform way:

# This IS your infrastructure. It's reviewable. It's version-controlled.
# If it's not in here, it doesn't exist.
resource "aws_instance" "web" {
  ami           = "ami-0c55b159cbfafe1f0"
  instance_type = "t3.medium"

  tags = {
    Name        = "web-server"
    Environment = "production"
    Team        = "backend"
  }
}

If someone changes the instance type, you see it in the PR. You review it. You merge it. History exists. Sleep is possible again. 🛌

The Horror Story That Started It All 💀

Back when I was setting up AWS deployments for a Laravel monolith, we had three environments: dev, staging, production. All configured manually over 18 months.

One day, a junior developer needed to spin up a fourth environment for a client demo. He asked: "Can you just tell me how staging is set up?"

I opened the staging console and stared blankly.

Security groups? 14 rules, assembled over time like geological strata. RDS instance? Multi-AZ? I... think so? Load balancer listeners? Some of them had HTTP redirects, one didn't. Nobody knew why.

# What we wanted to say:
"Here's the config, just run it!"

# What we actually said:
"Um. Okay so first go to EC2, then...
actually wait, check VPCs first...
no, start with the security groups...
you know what, let me just do it."
# 4 hours later, still clicking

After countless deployments like this, I said enough. Enter Terraform.

Your First Terraform Config (For Real) 🚀

Install it:

brew install terraform   # macOS
# or
winget install HashiCorp.Terraform  # Windows
# or just grab the binary from terraform.io

Initialize a project:

mkdir my-infra && cd my-infra
terraform init

The three commands you'll live by:

terraform plan    # "What WOULD happen if I applied this?"
terraform apply   # "Make it so!"
terraform destroy # "Burn it all down." (use with caution 😅)

A Real-World Example: Laravel App on AWS 🐘

Here's the kind of setup I've used for Laravel applications in production:

# main.tf

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

  # Store state in S3 (not your laptop!)
  backend "s3" {
    bucket = "my-terraform-state-bucket"
    key    = "laravel-app/terraform.tfstate"
    region = "us-east-1"
  }
}

provider "aws" {
  region = var.aws_region
}

# ─── VPC ──────────────────────────────────────────────
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 5.0"

  name = "${var.app_name}-vpc"
  cidr = "10.0.0.0/16"

  azs             = ["us-east-1a", "us-east-1b"]
  private_subnets = ["10.0.1.0/24", "10.0.2.0/24"]
  public_subnets  = ["10.0.101.0/24", "10.0.102.0/24"]

  enable_nat_gateway = true
}

# ─── RDS (MySQL) ───────────────────────────────────────
resource "aws_db_instance" "main" {
  identifier        = "${var.app_name}-db"
  engine            = "mysql"
  engine_version    = "8.0"
  instance_class    = "db.t3.medium"
  allocated_storage = 20

  db_name  = var.db_name
  username = var.db_username
  password = var.db_password  # Use AWS Secrets Manager in prod!

  vpc_security_group_ids = [aws_security_group.db.id]
  db_subnet_group_name   = aws_db_subnet_group.main.name

  multi_az            = true   # HA for production
  skip_final_snapshot = false

  tags = local.common_tags
}

# ─── ECS Cluster (Docker!) ─────────────────────────────
resource "aws_ecs_cluster" "main" {
  name = "${var.app_name}-cluster"
}

resource "aws_ecs_task_definition" "app" {
  family                   = var.app_name
  network_mode             = "awsvpc"
  requires_compatibilities = ["FARGATE"]
  cpu                      = "512"
  memory                   = "1024"
  execution_role_arn       = aws_iam_role.ecs_execution.arn

  container_definitions = jsonencode([{
    name  = var.app_name
    image = "${var.ecr_repo_url}:${var.image_tag}"

    portMappings = [{
      containerPort = 80
      protocol      = "tcp"
    }]

    environment = [
      { name = "APP_ENV", value = "production" },
      { name = "DB_HOST",  value = aws_db_instance.main.address }
    ]

    logConfiguration = {
      logDriver = "awslogs"
      options = {
        "awslogs-group"  = "/ecs/${var.app_name}"
        "awslogs-region" = var.aws_region
      }
    }
  }])
}

The magic: This entire setup is reproducible. New team member? git clone, terraform apply. New environment? Change var.environment from staging to production. Done.

Variables: Stop Hardcoding Everything 🔧

variables.tf:

variable "app_name" {
  description = "Application name (used for naming all resources)"
  type        = string
}

variable "environment" {
  description = "Environment: dev, staging, or production"
  type        = string

  validation {
    condition     = contains(["dev", "staging", "production"], var.environment)
    error_message = "Environment must be dev, staging, or production."
  }
}

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

variable "db_password" {
  description = "Database password"
  type        = string
  sensitive   = true  # Won't show in logs!
}

terraform.tfvars (DON'T commit this!):

app_name    = "my-laravel-app"
environment = "production"
db_password = "super-secret-never-commit-this"

.gitignore:

*.tfvars
*.tfvars.json
.terraform/
*.tfstate
*.tfstate.backup

Terraform taught me the hard way: Commit your .tf files. Never commit your .tfvars with secrets. Use AWS Secrets Manager or GitHub Actions secrets for the real values.

The terraform plan Is Your Best Friend 🔍

Before I found Terraform, deploying infrastructure changes felt like changing airplane engines mid-flight. With terraform plan, you get a preview:

$ terraform plan

Terraform will perform the following actions:

  # aws_db_instance.main will be updated in-place
  ~ resource "aws_db_instance" "main" {
      ~ instance_class = "db.t3.medium" -> "db.t3.large"
        # (everything else stays the same)
    }

  # aws_ecs_service.app will be updated in-place
  ~ resource "aws_ecs_service" "app" {
      ~ desired_count = 2 -> 4
    }

Plan: 0 to add, 2 to change, 0 to destroy.

You see exactly what changes. You can review it. You can say "wait, why is it destroying the DB?" and catch disasters before they happen.

A CI/CD pipeline that saved our team: We run terraform plan on every PR and post the output as a comment. Infrastructure changes get reviewed the same way code changes do. ðŸŽŊ

Workspaces: One Codebase, Multiple Environments âœĻ

Before Terraform:

/infrastructure
  /dev         ← copy-paste hell
  /staging     ← 60% different from dev, nobody knows why
  /production  ← "don't touch it's working"

With Terraform workspaces:

# Create environments
terraform workspace new dev
terraform workspace new staging
terraform workspace new production

# Switch between them
terraform workspace select staging

# Your .tf files handle the differences:
locals {
  # Settings that differ by environment
  env_config = {
    dev = {
      instance_type = "t3.small"
      db_class      = "db.t3.micro"
      desired_count = 1
      multi_az      = false
    }
    staging = {
      instance_type = "t3.medium"
      db_class      = "db.t3.small"
      desired_count = 2
      multi_az      = false
    }
    production = {
      instance_type = "t3.large"
      db_class      = "db.t3.medium"
      desired_count = 4
      multi_az      = true
    }
  }

  # Use current workspace name
  config = local.env_config[terraform.workspace]
}

resource "aws_db_instance" "main" {
  instance_class = local.config.db_class
  multi_az       = local.config.multi_az
  # ...
}

One codebase. Three environments. Zero copy-paste. After setting up CI/CD for Node.js and Laravel projects this way, I'll never go back to manual environment management.

Plug Into GitHub Actions ðŸĪ–

The full IaC CI/CD flow:

# .github/workflows/terraform.yml
name: Terraform

on:
  pull_request:
    paths:
      - 'infrastructure/**'
  push:
    branches: [main]
    paths:
      - 'infrastructure/**'

jobs:
  terraform:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: ./infrastructure

    steps:
      - uses: actions/checkout@v4

      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: "1.7.0"

      - name: Configure AWS credentials
        uses: aws-actions/configure-aws-credentials@v4
        with:
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
          aws-region: us-east-1

      - name: Terraform Init
        run: terraform init

      - name: Terraform Format Check
        run: terraform fmt -check

      - name: Terraform Validate
        run: terraform validate

      # On PRs: plan and post output as comment
      - name: Terraform Plan
        if: github.event_name == 'pull_request'
        run: terraform plan -no-color
        id: plan

      - name: Comment PR with Plan
        if: github.event_name == 'pull_request'
        uses: actions/github-script@v7
        with:
          script: |
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: `## Terraform Plan\n\`\`\`\n${{ steps.plan.outputs.stdout }}\n\`\`\``
            })

      # On merge to main: actually apply
      - name: Terraform Apply
        if: github.ref == 'refs/heads/main' && github.event_name == 'push'
        run: terraform apply -auto-approve

The result: Infrastructure changes go through pull request review. You can see exactly what will change before it happens. No more 3 AM "who touched the security group?" drama.

Common Pitfalls to Avoid ðŸŠĪ

Pitfall #1: Storing State Locally

Bad:

# Default state goes to terraform.tfstate on your laptop
terraform apply
# Your laptop dies. State is gone. Chaos.

Good:

terraform {
  backend "s3" {
    bucket         = "my-terraform-state"
    key            = "app/terraform.tfstate"
    region         = "us-east-1"
    encrypt        = true
    dynamodb_table = "terraform-state-lock"  # Prevents concurrent applies!
  }
}

Pitfall #2: terraform destroy in Production

I once watched a developer run terraform destroy against production instead of staging because they forgot to switch workspaces.

Database. Gone. 3 PM on a Tuesday. ☠ïļ

Protect yourself:

resource "aws_db_instance" "main" {
  # ...

  lifecycle {
    prevent_destroy = true  # Terraform will error before deleting this!
  }
}

Now even an accidental destroy can't nuke your RDS. You'd have to remove this block and apply first. That friction saves lives.

Pitfall #3: Not Using depends_on

# This might fail because Terraform tries to create
# the ECS service before the DB is ready
resource "aws_ecs_service" "app" {
  # ...

  # Tell Terraform: wait for the DB first!
  depends_on = [aws_db_instance.main]
}

Pitfall #4: Giant Monolithic State Files

Bad: One giant main.tf with 500 resources. One change = 2 minute plan time.

Good: Split by responsibility:

infrastructure/
  networking/     # VPC, subnets, security groups
  database/       # RDS, ElastiCache
  compute/        # ECS, Lambda, EC2
  monitoring/     # CloudWatch, alarms
  cdn/            # CloudFront, S3

Each module has its own state file. Changes to networking don't require planning all 500 resources.

Before vs. After: The Real Difference 📊

Before (Console Cowboy) After (Terraform)
New environment setup 4+ hours of clicking terraform workspace new demo && terraform apply
"What changed in prod?" Shrug emoji ðŸĪ· git log infrastructure/
Disaster recovery Pray + memory terraform apply from scratch
Onboarding new team member Shadow me for 2 days Clone repo, read README
Compliance audit Screenshot hell Show the .tf files
Staging ↔ prod drift Constant mystery Zero — same code, different vars

The Bottom Line ðŸ’Ą

After countless deployments and one too many "works on staging, broken in prod" incidents, Terraform became non-negotiable for me.

Your infrastructure IS code. It should be:

  • Reviewed like code (PRs!)
  • Tested like code (terraform plan)
  • Versioned like code (Git history)
  • Reproducible like code (anyone can run it)

Start simple. Don't try to Terraform your entire AWS account on day one. Pick one thing — maybe just your ECS cluster or your RDS setup — and put it in Terraform. Build the habit. The rest follows.

The best infrastructure is the kind you can recreate from scratch in 10 minutes while your production environment is on fire and your hands are shaking. Terraform gets you there. ðŸ”Ĩ

Your Action Plan 🚀

This week:

  1. Install Terraform: brew install terraform
  2. Pick ONE existing resource (an S3 bucket is perfect)
  3. Import it: terraform import aws_s3_bucket.main my-bucket-name
  4. Start from there

This month:

  1. Move your core infrastructure to Terraform
  2. Set up remote state in S3 with DynamoDB locking
  3. Add terraform plan to your CI/CD pipeline
  4. Add prevent_destroy to your databases (seriously, do this now)

The rule I live by: If you can't terraform apply your production environment from a fresh AWS account in under 30 minutes, you don't control your infrastructure. It controls you. ðŸŽŊ


Still clicking around the AWS console? Find me on LinkedIn — let's talk about how to bring your infra under control!

Want to see real Terraform configs? Check out my GitHub for battle-tested templates.

Now go put your infrastructure in Git. Future you will sleep better. 🏗ïļâœĻ


P.S. The colleague who clicked "Enable auto-scaling" without telling anyone? He's now our biggest Terraform advocate. Growth. ðŸŒą