0x55aa
← Back to Blog

Terraform: Stop Click-Ops Before Your AWS Console Becomes a Crime Scene šŸ—ļøāš”

•8 min read

Terraform: Stop Click-Ops Before Your AWS Console Becomes a Crime Scene šŸ—ļøāš”

Confession: I once spent three hours trying to recreate an AWS environment for staging because nobody — including me — could remember exactly what the production setup looked like. Security groups, target groups, RDS parameter groups, IAM role policies... all clicked together manually over eighteen months of "I'll document this later."

Narrator: He did not document it later.

That staging environment was held together with vibes and browser history. The moment I ran terraform destroy on a test environment and rebuilt it from scratch in 8 minutes, I understood what I'd been missing. Infrastructure as Code isn't just a best practice. It's the difference between "I know exactly what's running in prod" and "honestly, your guess is as good as mine."

What "Click-Ops" Actually Costs You šŸ–±ļø

Click-ops is what happens when your infrastructure exists only inside the AWS console — created by hand, documented in no one's head, and completely unreproducible.

Click-ops problems I've lived through:

  • "Why is this security group allowing port 3306 to 0.0.0.0/0?" — Nobody knows. It's been there for two years.
  • A CI/CD pipeline that saved our team zero times because the IAM role it deployed to was misconfigured by hand and we couldn't diff it.
  • A new developer needed a staging environment. It took four days of "find the differences between prod and staging."
  • A prod outage caused by someone clicking the wrong thing in the console at 4 PM on a Friday. (They were trying to fix a different thing. They made it worse.)

Terraform fixes all of this. Your infrastructure is code. Code lives in Git. Git has diffs, history, pull requests, and blame.

The Absolute Basics āš™ļø

Terraform works in three steps: write your infrastructure as .tf files, plan what it will create/change/destroy, apply to make it real.

# main.tf — a minimal AWS setup that does something real

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

  # Store state remotely — never use local state in production
  backend "s3" {
    bucket         = "my-terraform-state-bucket"
    key            = "api/production/terraform.tfstate"
    region         = "ap-south-1"
    dynamodb_table = "terraform-state-lock"
    encrypt        = true
  }
}

provider "aws" {
  region = "ap-south-1"
}

Variables keep your config flexible:

# variables.tf
variable "environment" {
  description = "Deployment environment"
  type        = string
  default     = "production"
}

variable "app_port" {
  description = "Port the application listens on"
  type        = number
  default     = 3000
}

variable "db_password" {
  description = "RDS master password"
  type        = string
  sensitive   = true  # Won't appear in logs or plan output
}

A security group with actual human-readable intent:

# security-groups.tf
resource "aws_security_group" "api" {
  name        = "${var.environment}-api-sg"
  description = "API service — only HTTPS inbound from ALB"
  vpc_id      = aws_vpc.main.id

  ingress {
    description     = "HTTPS from ALB only"
    from_port       = var.app_port
    to_port         = var.app_port
    protocol        = "tcp"
    security_groups = [aws_security_group.alb.id]  # Not 0.0.0.0/0
  }

  egress {
    description = "Outbound to internet (for package downloads, APIs)"
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name        = "${var.environment}-api-sg"
    Environment = var.environment
    ManagedBy   = "terraform"  # Tag everything — your future self will thank you
  }
}

That description on the ingress rule is the thing nobody does when clicking in the console, and the thing you desperately need at 11 PM when debugging a network issue.

The Workflow That Changed Everything šŸ”„

# Initialize — download providers, set up backend
terraform init

# Plan — shows you EXACTLY what will change before you touch anything
terraform plan -out=tfplan

# The output looks like this:
# + aws_security_group.api will be created
# ~ aws_lb_target_group.api: health_check.interval: 30 → 15
# - aws_security_group.old_sg will be destroyed

# Apply — executes the plan you just reviewed
terraform apply tfplan

The plan step is what I now run on every pull request. A CI check that runs terraform plan and posts the diff as a PR comment has saved our team from three major misconfigurations I know about and probably several I don't.

Remote State: The Non-Negotiable Part šŸ—„ļø

Docker taught me the hard way that local state is a trap. Terraform state tracks what it has built — if that file lives on your laptop and your laptop dies, Terraform thinks nothing exists and will try to recreate everything.

Bootstrap your remote state first (just this once):

# bootstrap/main.tf — run this manually ONE TIME to create the state bucket
resource "aws_s3_bucket" "terraform_state" {
  bucket = "yourcompany-terraform-state"
}

resource "aws_s3_bucket_versioning" "terraform_state" {
  bucket = aws_s3_bucket.terraform_state.id
  versioning_configuration {
    status = "Enabled"  # Every state change is versioned — rollback is possible
  }
}

resource "aws_dynamodb_table" "terraform_locks" {
  name         = "terraform-state-lock"
  billing_mode = "PAY_PER_REQUEST"
  hash_key     = "LockID"

  attribute {
    name = "LockID"
    type = "S"
  }
}

The DynamoDB table prevents two people from running terraform apply simultaneously and corrupting the state. After countless deployments with multiple team members, this lock table has prevented exactly the kind of race condition that ruins your Monday morning.

Before vs. After: The Reality šŸ“Š

Situation Before Terraform After Terraform
"What's in production?" Log into console, click around terraform show — exact state, readable
Recreate staging 4 days of detective work terraform apply -var-file=staging.tfvars
Security group audit Click through 50 rules manually grep through .tf files in 10 seconds
IAM role changes "I think I added that permission" Git diff shows exactly what changed, when, by who
Rollback bad infra change AWS doesn't have an undo button git revert + terraform apply
Onboarding a new developer "Here's the console login, good luck" "Clone this repo, run terraform plan"
Cross-environment consistency Hope and prayer Same .tf files, different tfvars

Common Pitfalls to Avoid 🪤

Pitfall #1: Secrets in your .tf files

# Absolutely do not do this
resource "aws_db_instance" "api" {
  password = "SuperSecret123"  # 🚨 This ends up in state AND in Git history
}

# Do this instead
resource "aws_db_instance" "api" {
  password = var.db_password  # Pass via environment variable or secrets manager
}

And in CI:

- name: Apply Terraform
  env:
    TF_VAR_db_password: ${{ secrets.DB_PASSWORD }}  # From GitHub Secrets
  run: terraform apply -auto-approve tfplan

Pitfall #2: Not locking your provider versions

# Without version lock — a provider update can silently break your plan
provider "aws" {}

# With lock — reproducible across every machine and CI run
terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "= 5.31.0"  # Exact pin for production
    }
  }
}

Always commit your .terraform.lock.hcl file. It pins the provider checksums like a package-lock.json.

Pitfall #3: Running terraform apply without a saved plan

# Risky — the plan might differ from what you reviewed if state changed since
terraform apply

# Safe — applies exactly what you reviewed in the PR
terraform plan -out=tfplan
terraform apply tfplan

Between your plan and your apply, someone else might have applied changes. The saved plan file guarantees you apply what you saw.

Pitfall #4: One giant main.tf that manages everything

When your entire AWS account is one Terraform workspace, a mistake in terraform apply can nuke things you didn't intend to touch. Split by service, by environment, by blast radius:

terraform/
ā”œā”€ā”€ networking/      # VPC, subnets, route tables
ā”œā”€ā”€ iam/             # Roles, policies
ā”œā”€ā”€ database/        # RDS, parameter groups
└── application/     # ECS, ALB, Auto Scaling

Each directory has its own state. A bad plan in application/ can't touch networking/.

TL;DR āœ…

  • Terraform = your AWS infrastructure as .tf files that live in Git
  • terraform plan shows you every change before it happens — run it in PRs
  • Remote state in S3 + DynamoDB locking is non-negotiable for teams
  • Tag everything with Environment and ManagedBy = "terraform" — your audit trail depends on it
  • Never hardcode secrets — use sensitive = true variables and inject via environment
  • Lock provider versions in .terraform.lock.hcl — commit that file
  • Split infrastructure into separate workspaces by blast radius, not one massive state
  • The first time you terraform destroy a test environment and rebuild it in minutes, click-ops will feel like a crime

After countless deployments spent clicking through AWS consoles trying to remember what I'd built, Terraform is the tool I wish I'd started with. Your infrastructure is code now. Review it, version it, test it. Sleep better.


Building infra as code and want to compare approaches? Find me on LinkedIn — always happy to trade Terraform horror stories.

Want working examples? My GitHub has real Terraform modules for ECS, RDS, and VPC setups from production systems.

If you can't git diff your infrastructure, do you even know what's running in production? šŸ—ļø