Skip to content

Terraform Operations

All AWS infrastructure changes must go through Terraform. Never create or modify resources in the AWS Console. Always operate at the module level, never at the parent environment directory.

Repository: terraform-plan/


Module-Level Operations

The terraform-plan/ repository is organized into independent root modules. Each module under environments/{account}/ has its own backend.tf and provider.tf for independent state management.

Always run Terraform at the module level

Run terraform init, terraform plan, and terraform apply inside a specific module directory (e.g., environments/dev/eks-cluster/), NOT at the parent environments/dev/ directory. The parent directory does not contain a unified root module — each child is a standalone root module with its own state.

terraform-plan/
  organization/
    environments/
      shared-services/
        ec2-instances/
          gitlab/
          nexus/
          sonarqube/        <- run terraform here
      dev/
        eks-cluster/        <- run terraform here
        ec2-instances/
          orchestrator/     <- run terraform here

Standard Workflow

# 1. Navigate to the module directory
cd terraform-plan/organization/environments/dev/eks-cluster/

# 2. Initialize (downloads providers, configures backend)
terraform init

# 3. Plan — review all proposed changes before applying
terraform plan \
  -var="tailscale_auth_key=$TAILSCALE_AUTH_KEY" \
  -var="created_by=$CREATED_BY"

# 4. Apply — only after reviewing the plan output
terraform apply \
  -var="tailscale_auth_key=$TAILSCALE_AUTH_KEY" \
  -var="created_by=$CREATED_BY"

Required Variables

Variable Source Required Purpose
tailscale_auth_key .claude-env or Vault Yes (for modules with Tailscale resources) Tailscale provider authentication for ACL changes
created_by .claude-env Always mandatory Tags every AWS resource with the operator's identity

CREATED_BY is mandatory for all Terraform operations

The created_by variable tags every AWS resource with the operator's identity (e.g., ateodoro). This is required for accountability and cost attribution. If CREATED_BY is not set in your environment, set it before running any Terraform command:

export CREATED_BY=your.name
Never use a placeholder value or skip this tag.

Loading Variables from .claude-env

# Load environment variables from .claude-env
source .claude-env

# Verify they are set
echo $TAILSCALE_AUTH_KEY
echo $CREATED_BY

# Then run Terraform
terraform plan \
  -var="tailscale_auth_key=$TAILSCALE_AUTH_KEY" \
  -var="created_by=$CREATED_BY"

State Backend

All Terraform state is stored in S3 in the shared-services account, regardless of which account the resources are created in:

# backend.tf (example from dev/eks-cluster)
terraform {
  backend "s3" {
    bucket  = "cwiq-terraform-states"
    key     = "cwiq-io/dev/eks-cluster/terraform.tfstate"
    region  = "us-west-2"
    profile = "shared-services"
  }
}

State is always in shared-services, resources may be in dev

The profile = "shared-services" in backend.tf controls where state is stored. The profile in provider.tf controls where resources are created. A module can store state in shared-services while creating EC2 instances in the dev account.


Adding a New EC2 Instance

When adding a new EC2 instance via Terraform, you must follow these requirements:

1. Use AlmaLinux 9 as the Default AMI

All new EC2 instances must use AlmaLinux 9 unless there is a documented reason for a different OS:

AMI ID OS Region Use For
ami-02169c46e1cfcd5e7 AlmaLinux 9.7.20251118 x86_64 us-west-2 Default — all new instances
ami-08952be33a4ff3495 AlmaLinux 10.1.20251124 x86_64 us-west-2 Only if AL10 is explicitly required

AlmaLinux 10 compatibility

AlmaLinux 10 has known incompatibilities with SSSD proxy mode (PAM preauth task 249 bug in SSSD 2.11/PAM 1.6). Use AlmaLinux 9 for all new instances unless you have explicitly tested AL10 compatibility for your use case.

Copy the AMI variable from an existing module rather than hardcoding it:

# variables.tf
variable "ami_id" {
  description = "AlmaLinux 9 AMI ID (us-west-2)"
  type        = string
  default     = "ami-02169c46e1cfcd5e7"
}

2. Include a Cost Projection in Your MR

Every MR that adds a new EC2 instance must include a cost projection:

Cost Component Calculation
Instance (On-Demand) Monthly rate for the selected instance type in us-west-2
Root EBS (gp3) GB x $0.08/month
Data EBS (gp3) GB x $0.08/month
Compute Savings Plan ~47% discount on instance cost (3yr No Upfront)
Total monthly Instance + storage (after Savings Plan)

Quick reference — us-west-2 On-Demand rates:

Instance Type Monthly (On-Demand) With 3yr Savings Plan
t3.micro $7.59 ~$4.02
t3.small $15.18 ~$8.05
t3.medium $30.37 ~$16.10
t3.large $60.74 ~$32.19
t3.xlarge $121.47 ~$64.38

Full pricing analysis: terraform-plan/docs/cost/COST_OPTIMIZATION.md

3. Tag All Resources

The created_by variable must flow through to resource tags:

# main.tf
resource "aws_instance" "example" {
  ami           = var.ami_id
  instance_type = var.instance_type

  tags = {
    Name        = var.instance_name
    Environment = var.environment
    CreatedBy   = var.created_by
    ManagedBy   = "terraform"
  }
}

Provider Configuration

Each module's provider.tf specifies which AWS account it targets:

# provider.tf — dev resources
provider "aws" {
  region  = "us-west-2"
  profile = "dev"
}

# provider.tf — shared-services resources
provider "aws" {
  region  = "us-west-2"
  profile = "shared-services"
}

If you find a module without a profile in its provider configuration, do not run it. Ask the infrastructure team which account it should target before proceeding.


Reviewing a Plan Output

Before applying any Terraform plan, verify:

  1. No unexpected destroys — Look for # module.x will be destroyed. A destroy of a running service (EC2, RDS, EKS node group) requires explicit sign-off.
  2. Correct account — Confirm the provider profile matches the account where you expect changes.
  3. Resource count — The plan summary line (Plan: X to add, Y to change, Z to destroy) should match your intended scope.
  4. Tag coverage — Spot-check a resource's tags block to confirm created_by and ManagedBy are present.
# Save a plan to a file for review
terraform plan \
  -var="tailscale_auth_key=$TAILSCALE_AUTH_KEY" \
  -var="created_by=$CREATED_BY" \
  -out=tfplan

# Apply the saved plan (no re-evaluation)
terraform apply tfplan

What Terraform Manages

Resource Type Terraform Module Location
VPC, subnets, routing environments/{account}/vpc/
EC2 instances environments/{account}/ec2-instances/{service}/
IAM roles and policies environments/{account}/iam/
EKS cluster and node groups environments/dev/eks-cluster/
Route53 records environments/shared-services/route53/
Tailscale ACLs environments/shared-services/tailscale/
Security groups Co-located with the service module that owns them

  • AWS Accounts — Account IDs, purposes, and which account owns which resources
  • CLI Profiles — How to configure and verify AWS CLI profiles before running Terraform