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-env → TAILSCALE_AUTH_KEY |
created_by |
Tags every AWS resource with the operator's identity | .claude-env → CREATED_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:
- Instance cost — On-Demand monthly rate (us-west-2)
- Storage cost — EBS gp3 at $0.08/GB-month (root + data + containerd volumes)
- Savings Plan impact — ~47% discount with Compute Savings Plan (3yr No Upfront)
- 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"
}
}
Related Documentation¶
- IaC Principles
- Adding a New Server
- Source:
terraform-plan/docs/guides/add-ec2-instance.md - Source:
terraform-plan/docs/cost/COST_OPTIMIZATION.md