Skip to content

Terraform Patterns

Operational patterns for managing CWIQ AWS infrastructure with Terraform. The two most common mistakes — using the wrong AWS profile and running at the wrong directory level — will both silently destroy or create resources in the wrong account.


AWS Account Selection

CWIQ uses two AWS accounts. Always verify the account before running any Terraform command.

CRITICAL: Always specify an AWS profile

The default AWS profile (921838417607) is the management/root account. Never use it for infrastructure changes. Always pass --profile shared-services or --profile dev.

Account Account ID CLI Profile Purpose
shared-services 308188966547 shared-services GitLab, Authentik, Vault, Nexus, Observability, Route53 zones
dev 686123185567 dev DEV environment, EKS cluster, runners

Verify the account before every operation:

aws sts get-caller-identity --profile shared-services
# Expected: "Account": "308188966547"

aws sts get-caller-identity --profile dev
# Expected: "Account": "686123185567"

Each module's provider.tf must specify the profile. If a module is missing profile, ask the team before running — never use the default.


Module-Level Operations

CRITICAL: Always run Terraform at the module level

Never run terraform init, terraform plan, or terraform apply at the parent environments/dev/ directory. Each child module has its own independent backend.

The correct pattern:

# Correct — run at the module level
cd terraform-plan/organization/environments/dev/eks-cluster
terraform init
terraform plan -var="tailscale_auth_key=$env:TAILSCALE_AUTH_KEY" -var="created_by=$env:CREATED_BY"
terraform apply ...

# Wrong — running at the parent directory
cd terraform-plan/organization/environments/dev
terraform plan  # This will fail or produce unexpected results

Each module has its own backend.tf (S3 state file) and provider.tf (account + region). They are self-contained.


Mandatory Variables

Two variables are required for every Terraform operation:

Variable Purpose Source
tailscale_auth_key Tailscale provisioning key for new instances .claude-envTAILSCALE_AUTH_KEY
created_by Tags every AWS resource with the operator's identity .claude-envCREATED_BY

CREATED_BY is mandatory

If CREATED_BY is not set, stop and set it before running any Terraform commands. Every AWS resource must be tagged with the operator identity. Never use a placeholder.

Standard command pattern:

terraform plan \
  -var="tailscale_auth_key=$env:TAILSCALE_AUTH_KEY" \
  -var="created_by=$env:CREATED_BY"

Default AMI

Default OS: AlmaLinux 9

All new EC2 instances MUST use AlmaLinux 9 unless there is a documented reason for a different OS. AlmaLinux 10 has known incompatibilities with SSSD proxy mode.

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 explicitly required

Copy the AMI from an existing module rather than looking it up in the console:

grep ami_id terraform-plan/organization/environments/shared-services/ec2-instances/gitlab/variables.tf

Mandatory EC2 Cost Projection

When adding a new EC2 instance, include a cost projection in the PR description and any related documentation.

Required calculations:

  1. Instance cost — On-Demand monthly rate (us-west-2)
  2. Storage cost — EBS gp3 at $0.08/GB-month (root + data + containerd volumes)
  3. Savings Plan impact — ~47% discount with Compute Savings Plan (3yr No Upfront)
  4. Total monthly cost

Quick reference (us-west-2 On-Demand):

Type vCPU RAM Monthly
t3.micro 2 1 GiB $7.59
t3.small 2 2 GiB $15.18
t3.medium 2 4 GiB $30.37
t3.large 2 8 GiB $60.74
t3.xlarge 4 16 GiB $121.47

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


Module File Structure

Each EC2 instance module is self-contained:

File Always Present Purpose
main.tf Yes EC2 instance using the shared ec2-instance module
variables.tf Yes Input variables with defaults
outputs.tf Yes Instance ID, IPs, SSH command
backend.tf Yes S3 remote state (independent per module)
provider.tf Yes AWS provider with explicit profile
iam.tf Optional IAM role if app needs AWS permissions
route53.tf Optional DNS records
security.tf Optional (rare) App-specific security group
user_data.sh Optional Instance initialization script

Creating a New EC2 Module

Use the provided script for new instances:

cd terraform-plan
./scripts/new-app.sh <app-name> [environment] [description]

# Example:
./scripts/new-app.sh langfuse dev "LangFuse LLM Observability"

Then customize the generated files:

cd organization/environments/dev/ec2-instances/langfuse

# Review and edit variables.tf
# - ami_id: use ami-02169c46e1cfcd5e7 (AlmaLinux 9)
# - instance_type: size appropriately
# - data_volume_size: set for /data
# - create_data_volume2: true if separate /var/lib/containerd volume needed

terraform init
terraform plan \
  -var="tailscale_auth_key=$env:TAILSCALE_AUTH_KEY" \
  -var="created_by=$env:CREATED_BY"
terraform apply \
  -var="tailscale_auth_key=$env:TAILSCALE_AUTH_KEY" \
  -var="created_by=$env:CREATED_BY"

State Backend

Terraform state is stored in S3 in the shared-services account:

Setting Value
Bucket cwiq-terraform-states
Key pattern cwiq-io/{env}/{module}/terraform.tfstate
Profile shared-services
Region us-west-2
Lock DynamoDB table cwiq-terraform-locks

Example backend.tf:

terraform {
  backend "s3" {
    bucket  = "cwiq-terraform-states"
    key     = "cwiq-io/dev/langfuse/terraform.tfstate"
    region  = "us-west-2"
    profile = "shared-services"
    dynamodb_table = "cwiq-terraform-locks"
  }
}