Skip to main content
Cloud DevOps Solutions
Get a quote Talk to an engineer
Infrastructure as Code 2025-11-05 15 min read

ClickOps vs Infrastructure as Code: How to Move Your AWS Setup to Terraform

Saddique Khan

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/0 ingress 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.
#terraform #aws #iac #clickops
Found this useful? Share it with your team or link to it from your documentation.

Need help implementing this?

Our certified engineers can handle the setup, migration, or ongoing operations for you.