Terraform: Stop Clicking Around the AWS Console Like It's 2015 ðïļ
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:
- Install Terraform:
brew install terraform - Pick ONE existing resource (an S3 bucket is perfect)
- Import it:
terraform import aws_s3_bucket.main my-bucket-name - Start from there
This month:
- Move your core infrastructure to Terraform
- Set up remote state in S3 with DynamoDB locking
- Add
terraform planto your CI/CD pipeline - Add
prevent_destroyto 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. ðą