ClickOps vs Infrastructure as Code: How to Move Your AWS Setup to Terraform
DevOps / SRE Engineer & CEO, Cloud DevOps Solutions
ClickOps, clicking through the AWS console to create and configure resources, works fine for the first few months. Then it becomes a liability: no audit trail, no way to reproduce environments, no way to safely make changes, and tribal knowledge that leaves with whoever set it up. Moving to Terraform fixes all of this.
Why ClickOps breaks at scale
The failure modes are predictable:
- Drift: console and reality diverge. Someone “quick-fixed” a security group rule and nobody documented it.
- No rollback: if you misconfigure an RDS instance, you cannot
git revert. - Environment inconsistency: production and staging look similar but differ in 20 ways you will find during an incident.
- Onboarding cost: new engineers need weeks to understand what exists and why.
- Compliance gaps: auditors want evidence of who changed what and when. Console history is incomplete.
Terraform solves all of these by making infrastructure a reviewed, versioned artefact.
Step 1: Import existing resources
The biggest fear with migrating to Terraform is breaking what already exists. terraform import handles this, it pulls existing resources into Terraform state without recreating them.
# Install Terraform
brew tap hashicorp/tap && brew install hashicorp/tap/terraform
# Initialise state backend first (S3 + DynamoDB, see below)
terraform init
# Import an existing VPC
terraform import aws_vpc.main vpc-0123456789abcdef0
# Import an existing EC2 instance
terraform import aws_instance.web i-0123456789abcdef0
# Import an RDS instance
terraform import aws_db_instance.postgres mydbinstance
# Import a security group
terraform import aws_security_group.app sg-0123456789abcdef0
After import, terraform plan should show no changes. If it shows changes, your .tf code does not match reality, fix the code until the plan is clean before proceeding.
Use terraformer for bulk import
For existing accounts with dozens of resources, use terraformer to generate Terraform code automatically:
# Install
brew install googlecloudplatform/terraformer/terraformer
# Generate Terraform code for all VPCs and subnets in us-east-1
terraformer import aws \
--resources=vpc,subnet,security_group,internet_gateway,route_table \
--regions=us-east-1 \
--profile=default
# Output lands in generated/aws/
The output is a starting point, expect to refactor it into proper modules.
Step 2: Remote state with S3 + DynamoDB
Local state is fine for solo experimentation. For teams, use S3 with DynamoDB locking.
# backend.tf
terraform {
backend "s3" {
bucket = "my-company-terraform-state"
key = "production/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "terraform-state-lock"
}
}
Create the backend infrastructure first (manually, once):
aws s3api create-bucket \
--bucket my-company-terraform-state \
--region us-east-1
aws s3api put-bucket-versioning \
--bucket my-company-terraform-state \
--versioning-configuration Status=Enabled
aws s3api put-bucket-encryption \
--bucket my-company-terraform-state \
--server-side-encryption-configuration '{
"Rules": [{"ApplyServerSideEncryptionByDefault": {"SSEAlgorithm": "AES256"}}]
}'
aws dynamodb create-table \
--table-name terraform-state-lock \
--attribute-definitions AttributeName=LockID,AttributeType=S \
--key-schema AttributeName=LockID,KeyType=HASH \
--billing-mode PAY_PER_REQUEST
Step 3: Module structure
Flat Terraform files become unmanageable fast. Use a module structure:
terraform/
├── modules/
│ ├── vpc/ # reusable VPC module
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── outputs.tf
│ ├── eks/ # reusable EKS module
│ └── rds/
├── environments/
│ ├── staging/
│ │ ├── main.tf # calls modules
│ │ ├── variables.tf
│ │ └── terraform.tfvars
│ └── production/
│ ├── main.tf
│ ├── variables.tf
│ └── terraform.tfvars
└── shared/ # shared resources (Route53, ACM)
└── main.tf
# environments/production/main.tf
module "vpc" {
source = "../../modules/vpc"
name = "prod-vpc"
cidr_block = var.vpc_cidr
azs = ["us-east-1a", "us-east-1b", "us-east-1c"]
private_subnets = var.private_subnet_cidrs
public_subnets = var.public_subnet_cidrs
enable_nat_gateway = true
single_nat_gateway = false # one per AZ for HA
}
module "eks" {
source = "../../modules/eks"
cluster_name = "prod-cluster"
cluster_version = "1.31"
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnet_ids
}
Step 4: Naming conventions
Consistent naming makes terraform state list readable and prevents conflicts.
{company}-{environment}-{resource-type}-{identifier}
Examples:
myco-prod-vpc-main
myco-prod-eks-workers
myco-staging-rds-postgres
myco-prod-sg-alb-ingress
Use variables to enforce this:
locals {
prefix = "${var.company}-${var.environment}"
common_tags = {
Company = var.company
Environment = var.environment
ManagedBy = "terraform"
Repository = "github.com/myco/infra"
}
}
resource "aws_vpc" "main" {
cidr_block = var.cidr_block
tags = merge(local.common_tags, { Name = "${local.prefix}-vpc-main" })
}
Step 5: CI/CD for Terraform
Plan on PR, apply on merge. Never apply manually.
# .github/workflows/terraform.yml
name: Terraform
on:
push:
branches: [main]
paths: ['terraform/**']
pull_request:
paths: ['terraform/**']
permissions:
id-token: write # for OIDC auth to AWS
contents: read
pull-requests: write
jobs:
plan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-terraform
aws-region: us-east-1
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.9.0
- run: terraform init
working-directory: terraform/environments/production
- id: plan
run: terraform plan -no-color -out=tfplan
working-directory: terraform/environments/production
- uses: actions/github-script@v7
if: github.event_name == 'pull_request'
with:
script: |
const output = `#### Terraform Plan\n\`\`\`\n${{ steps.plan.outputs.stdout }}\n\`\`\``;
github.rest.issues.createComment({ issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, body: output });
apply:
runs-on: ubuntu-latest
needs: plan
if: github.ref == 'refs/heads/main'
environment: production
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-terraform
aws-region: us-east-1
- uses: hashicorp/setup-terraform@v3
- run: terraform init && terraform apply -auto-approve
working-directory: terraform/environments/production
Step 6: Drift detection
Schedule a daily terraform plan and alert if drift is found:
# .github/workflows/drift-detect.yml
on:
schedule:
- cron: '0 8 * * *' # 8am UTC daily
jobs:
drift:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-terraform
aws-region: us-east-1
- uses: hashicorp/setup-terraform@v3
- id: plan
run: |
terraform init
terraform plan -detailed-exitcode -no-color
echo "exitcode=$?" >> $GITHUB_OUTPUT
working-directory: terraform/environments/production
continue-on-error: true
- if: steps.plan.outputs.exitcode == '2'
run: |
echo "::warning::Drift detected in production Terraform state"
# Post to Slack, create a ticket, etc.
Security scanning with Checkov
pip install checkov
# Scan Terraform code before apply
checkov -d terraform/environments/production \
--framework terraform \
--output cli \
--soft-fail # warning, not failure, during adoption phase
Common Checkov findings in ClickOps-converted code:
- S3 buckets without server-side encryption
- Security groups with
0.0.0.0/0ingress on port 22 - RDS instances without deletion protection
- IAM policies with
*actions
Key takeaways
- Start with
terraform import, never recreate existing resources; the risk is not worth it. - State in S3 + DynamoDB is the minimum for any team of more than one person.
- Module structure per environment prevents accidental production changes during staging work.
- Plan on PR, apply on merge, manual applies are the root cause of most IaC incidents.
- Drift detection catches console cowboys who “just quickly” changed something.
- Checkov finds security issues before they reach production.