Complete 14-phase system for production-grade infrastructure as code. Zero dependencies — works with any cloud provider and any Terraform version.
Run this 8-signal triage on any Terraform project:
#Signal✅ Healthy🔴 Fix Now1Remote state backendS3/GCS/Azure Blob with lockingLocal state or no locking2State encryptionEncrypted at rest + restricted accessPlain state, wide access3Module pinningAll modules version-pinnedUnpinned or ref=main4Provider pinningrequired_providers with ~> constraintsNo version constraints5Separate environmentsIsolated state per env (dev/staging/prod)Shared state or workspaces-as-envs6Plan before applyCI runs plan, human approves, CI runs applyLocal apply without review7Secrets managementNo secrets in .tf files; vault/SSM/secrets managerHardcoded secrets anywhere8Drift detectionScheduled drift checks (weekly minimum)No drift monitoring
Score: /16 (2 per signal). Below 10 = stop and fix foundations first.
text
infrastructure/
├── modules/ # Reusable modules (internal registry)
│ ├── networking/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ ├── outputs.tf
│ │ ├── versions.tf
│ │ └── README.md
│ ├── compute/
│ ├── database/
│ └── monitoring/
├── environments/ # Environment-specific configs
│ ├── dev/
│ │ ├── main.tf # Module calls with dev params
│ │ ├── backend.tf # Dev state backend
│ │ ├── terraform.tfvars # Dev variable values
│ │ └── versions.tf
│ ├── staging/
│ └── prod/
├── global/ # Shared resources (IAM, DNS, etc.)
│ ├── iam/
│ ├── dns/
│ └── networking/
├── scripts/ # Helper scripts (import, migration)
├── policies/ # OPA/Sentinel policies
└── .github/workflows/ # CI/CD pipelines
.terraform.lock.hcl committed — reproducible provider versionsFilePurposemain.tfPrimary resource definitionsvariables.tfAll input variablesoutputs.tfAll outputsversions.tf``terraform and required_providers blocksbackend.tfState backend configurationlocals.tfLocal values and computed expressionsdata.tfData sourcesproviders.tfProvider configuration (if complex)
hcl
# backend.tf
terraform {
backend "s3" {
bucket = "company-terraform-state"
key = "environments/prod/networking/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "terraform-state-lock"
kms_key_id = "alias/terraform-state"
}
}
text
{org}/{environment}/{component}/terraform.tfstate
Examples:
acme/prod/networking/terraform.tfstateacme/prod/compute/terraform.tfstateacme/global/iam/terraform.tfstateOperationRiskSafe Approachterraform state mvMediumPlan after to verify no changesterraform state rmHighOnly to adopt resource elsewhereterraform importMediumWrite config first, import, plan to verifyterraform state pullLowFor inspection onlyterraform state pushCRITICALAlmost never — breaks consistencymoved blockLowPreferred over state mv — in config, reviewable
hcl
# variables.tf — Module inputs
variable "name" {
description = "Name prefix for all resources"
type = string
validation {
condition = can(regex("^[a-z][a-z0-9-]{2,28}[a-z0-9]$", var.name))
error_message = "Name must be 4-30 chars, lowercase alphanumeric + hyphens."
}
}
variable "environment" {
description = "Deployment environment"
type = string
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "Environment must be dev, staging, or prod."
}
}
variable "tags" {
description = "Common tags applied to all resources"
type = map(string)
default = {}
}
hcl
# outputs.tf — Module contract
output "vpc_id" {
description = "ID of the created VPC"
value = aws_vpc.main.id
}
output "private_subnet_ids" {
description = "List of private subnet IDs"
value = aws_subnet.private[*].id
}
hcl
# environments/prod/main.tf
module "networking" {
source = "../../modules/networking"
name = "prod"
environment = "prod"
vpc_cidr = "10.0.0.0/16"
azs = ["us-east-1a", "us-east-1b", "us-east-1c"]
tags = local.common_tags
}
module "compute" {
source = "../../modules/compute"
name = "prod"
environment = "prod"
vpc_id = module.networking.vpc_id
private_subnet_ids = module.networking.private_subnet_ids
instance_type = "t3.large"
min_size = 3
max_size = 10
tags = local.common_tags
}
for_each over count — stable resource addressingvalidation blocks catch errors at plan time~> for providersmoved blocks for refactoring — not state mvexamples/ directory with working configurationsAspectDevStagingProdInstance sizesSmall/microMatch prod typesRight-sizedReplica count123+ (HA)Multi-AZOptionalYesYesBackup retention1 day7 days30+ daysMonitoringBasicFullFull + PagerDutyAuto-scalingOffOnOnWAF/ShieldOffOnOn + AdvancedState accessDev teamDevOpsDevOps only
hcl
# modules/compute/variables.tf
variable "instance_type" {
type = string
default = "t3.micro" # Safe default
}
variable "min_size" {
type = number
default = 1
}
variable "enable_deletion_protection" {
type = bool
default = true # Safe default — must explicitly disable for dev
}
hcl
# environments/dev/terraform.tfvars
instance_type = "t3.micro"
min_size = 1
enable_deletion_protection = false
# environments/prod/terraform.tfvars
instance_type = "t3.large"
min_size = 3
enable_deletion_protection = true
text
dev → staging → prod
│ │ │
│ │ └─ Manual approval required
│ └─ Auto-apply after plan review
└─ Auto-apply on merge to dev branch
P0 — Mandatory:
.tf files, .tfvars, or state (use vault/SSM/secrets manager)prevent_destroy on critical resources (databases, S3 with data).gitignore includes *.tfvars with secrets, .terraform/, *.tfstate*P1 — Required:
0.0.0.0/0 ingress except ALB on 443)P2 — Recommended:
tfsec or checkov in CI pipelinetext
Need a secret in Terraform?
├── Runtime secret (app needs at runtime)
│ └── Use AWS Secrets Manager / HashiCorp Vault
│ └── Reference via data source, pass ARN to app
├── Terraform-time secret (provider needs it)
│ └── Environment variable (TF_VAR_xxx) or OIDC
└── Generated secret (Terraform creates it)
└── random_password resource → store in Secrets Manager
└── Mark output as sensitive = true
hcl
# No access keys needed
data "aws_iam_openid_connect_provider" "github" {
url = "https://token.actions.githubusercontent.com"
}
resource "aws_iam_role" "terraform_ci" {
name = "terraform-ci"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Federated = data.aws_iam_openid_connect_provider.github.arn }
Action = "sts:AssumeRoleWithWebIdentity"
Condition = {
StringEquals = {
"token.actions.githubusercontent.com:aud" = "sts.amazonaws.com"
}
StringLike = {
"token.actions.githubusercontent.com:sub" = "repo:org/infra:*"
}
}
}]
})
}
LevelToolWhat It TestsWhenStaticterraform validate, tflint, tfsec, checkovSyntax, best practices, securityEvery commitPlanterraform plan + policy checksExpected changes, no surprisesEvery PRContractterratest / tftest (TF 1.6+)Module inputs/outputs, behaviorPR + nightlyIntegrationterratest with real cloudActual infrastructure worksNightly/weekly
hcl
# tests/networking.tftest.hcl
run "creates_vpc_with_correct_cidr" {
command = plan
variables {
name = "test"
environment = "dev"
vpc_cidr = "10.0.0.0/16"
azs = ["us-east-1a"]
}
assert {
condition = aws_vpc.main.cidr_block == "10.0.0.0/16"
error_message = "VPC CIDR doesn't match input"
}
assert {
condition = aws_vpc.main.enable_dns_hostnames == true
error_message = "DNS hostnames should be enabled"
}
}
run "rejects_invalid_environment" {
command = plan
expect_failures = [var.environment]
variables {
name = "test"
environment = "invalid"
vpc_cidr = "10.0.0.0/16"
azs = ["us-east-1a"]
}
}
yaml
- name: Terraform Lint & Security
run: |
terraform fmt -check -recursive
terraform validate
tflint --recursive
tfsec .
checkov -d . --framework terraform
terraform test is built-in, use itdefer cleanup to avoid orphaned resourcesinfracost to catch expensive surprisesyaml
name: Terraform
on:
pull_request:
paths: ['infrastructure/**']
push:
branches: [main]
paths: ['infrastructure/**']
permissions:
id-token: write # OIDC
contents: read
pull-requests: write # PR comments
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: "1.7.x"
- run: terraform fmt -check -recursive
- run: terraform init -backend=false
- run: terraform validate
- run: tflint --recursive
- run: tfsec . --soft-fail
plan:
needs: validate
runs-on: ubuntu-latest
strategy:
matrix:
environment: [dev, staging, prod]
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::role/terraform-ci
aws-region: us-east-1
- uses: hashicorp/setup-terraform@v3
- working-directory: infrastructure/environments/${{ matrix.environment }}
run: |
terraform init
terraform plan -out=tfplan -no-color
- uses: actions/upload-artifact@v4
with:
name: tfplan-${{ matrix.environment }}
path: infrastructure/environments/${{ matrix.environment }}/tfplan
apply:
if: github.ref == 'refs/heads/main'
needs: plan
runs-on: ubuntu-latest
environment: production # Requires approval
strategy:
matrix:
environment: [dev, staging, prod]
max-parallel: 1 # Sequential: dev → staging → prod
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::role/terraform-ci
aws-region: us-east-1
- uses: hashicorp/setup-terraform@v3
- uses: actions/download-artifact@v4
with:
name: tfplan-${{ matrix.environment }}
path: infrastructure/environments/${{ matrix.environment }}
- working-directory: infrastructure/environments/${{ matrix.environment }}
run: terraform apply tfplan
apply from local machines — CI/CD onlyplan to detect manual changeshcl
locals {
common_tags = {
Project = var.project_name
Environment = var.environment
ManagedBy = "terraform"
Team = var.team
CostCenter = var.cost_center
Repository = "github.com/org/infrastructure"
}
}
# Apply to all resources
resource "aws_instance" "app" {
# ...
tags = merge(local.common_tags, {
Name = "${var.name}-app"
Role = "application"
})
}
text
{project}-{environment}-{component}-{qualifier}
Examples: acme-prod-vpc-main, acme-staging-rds-primary, acme-prod-alb-api
Conditional Resource Creation:
hcl
resource "aws_cloudwatch_metric_alarm" "cpu" {
count = var.environment == "prod" ? 1 : 0
# Only create alarms in prod
}
Dynamic Blocks:
hcl
resource "aws_security_group" "app" {
name = "${var.name}-app"
vpc_id = var.vpc_id
dynamic "ingress" {
for_each = var.ingress_rules
content {
from_port = ingress.value.port
to_port = ingress.value.port
protocol = "tcp"
cidr_blocks = ingress.value.cidrs
description = ingress.value.description
}
}
}
Data Source for Cross-Stack References:
hcl
# Instead of hardcoding VPC ID
data "terraform_remote_state" "networking" {
backend = "s3"
config = {
bucket = "company-terraform-state"
key = "environments/prod/networking/terraform.tfstate"
region = "us-east-1"
}
}
# Use: data.terraform_remote_state.networking.outputs.vpc_id
yaml
# .github/workflows/drift-detection.yml
name: Drift Detection
on:
schedule:
- cron: '0 8 * * 1' # Weekly Monday 8 AM UTC
jobs:
detect:
runs-on: ubuntu-latest
strategy:
matrix:
environment: [dev, staging, prod]
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::role/terraform-ci
aws-region: us-east-1
- uses: hashicorp/setup-terraform@v3
- working-directory: infrastructure/environments/${{ matrix.environment }}
run: |
terraform init
terraform plan -detailed-exitcode -no-color 2>&1 | tee plan.txt
EXIT_CODE=$?
if [ $EXIT_CODE -eq 2 ]; then
echo "::warning::Drift detected in ${{ matrix.environment }}"
# Send Slack alert
fi
Drift TypeResponseManual console change (cosmetic)Import or update config to matchManual console change (critical)Investigate who/why, then alignAuto-scaling / ASG changesExpected — use ignore_changes for dynamic attributesAWS service updatesUpdate provider version, review changelogSecurity group modified manually🚨 Security incident — investigate immediately
ignore_changes Decision Guide#Use ignore_changes ONLY for:
Never ignore_changes for:
yaml
- name: Infracost
run: |
infracost breakdown --path infrastructure/environments/prod/ \
--format json --out-file infracost.json
infracost output --path infracost.json --format github-comment \
--out-file comment.md
# Post as PR comment
StrategySavingsImplementationReserved Instances / Savings Plans30-60%Annual commitment for stable workloadsRight-sizing20-40%Monitor CPU/memory, downsize over-provisionedSpot/Preemptible for non-critical60-90%Batch jobs, dev environmentsS3 lifecycle policies20-50% storageTransition to IA → Glacier → deleteNAT Gateway alternatives$30-100/mo per GWNAT instances for dev, VPC endpointsDev environment scheduling60-70%Destroy nights/weekends, recreate on demandUnused resource cleanupVariableTag with TTL, auto-delete untagged after 7 days
Required cost tags (enforce via policy):
CostCenter — maps to business unitEnvironment — dev/staging/prodProject — which project owns thisTeam — responsible teamManagedBy — terraform/manual/otherWhen you have 5+ environments with identical module structures, Terragrunt eliminates repetition:
hcl
# terragrunt.hcl (root)
remote_state {
backend = "s3"
generate = { path = "backend.tf", if_exists = "overwrite_terragrunt" }
config = {
bucket = "company-terraform-state"
key = "${path_relative_to_include()}/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "terraform-state-lock"
}
}
hcl
# Declarative import — reviewable in PR
import {
to = aws_s3_bucket.existing
id = "my-existing-bucket"
}
resource "aws_s3_bucket" "existing" {
bucket = "my-existing-bucket"
# Write config to match existing resource
}
hcl
provider "aws" {
region = "us-east-1"
}
provider "aws" {
alias = "eu"
region = "eu-west-1"
}
module "eu_networking" {
source = "../../modules/networking"
providers = { aws = aws.eu }
# ...
}
moved Block for Refactoring#hcl
# Rename without destroy+create
moved {
from = aws_instance.app
to = aws_instance.application
}
# Move into module
moved {
from = aws_instance.app
to = module.compute.aws_instance.app
}
bash
# Enable versioning on state bucket (BEFORE you need it)
aws s3api put-bucket-versioning \
--bucket company-terraform-state \
--versioning-configuration Status=Enabled
# List state versions
aws s3api list-object-versions \
--bucket company-terraform-state \
--prefix environments/prod/networking/terraform.tfstate
# Restore previous version
aws s3api get-object \
--bucket company-terraform-state \
--key environments/prod/networking/terraform.tfstate \
--version-id "versionId123" \
restored-state.tfstate
terraform state pull > backup.tfstate — backup current statebackend.tf with new backend configterraform init -migrate-state — Terraform copies stateterraform plan — verify no changes (state matches)When upgrading major Terraform or provider versions:
.terraform.lock.hclterraform plan in all environmentsDimensionWeightScore RangeState management20%0-20Security posture20%0-20Module design15%0-15Testing coverage15%0-15CI/CD automation10%0-10Documentation10%0-10Cost governance5%0-5Drift management5%0-5
Scoring Guide:
for_each over count — stable addressing saves youterraform apply in prodMistakeImpactFixLocal state for team projectsState conflicts, data lossRemote backend day 1Secrets in .tfvars committed to gitCredential exposureUse vault/SSM + env varscount for optional resourcesIndex shift on removalfor_each with conditional mapMonolithic state fileSlow plans, blast radiusSplit by component (networking/compute/data)No prevent_destroy on data storesAccidental database deletionLifecycle rule on stateful resourcesUnpinned module versionsBreaking changes on initPin with ?ref=v1.2.3 or version = "~> 1.2"``terraform apply -auto-approve in prodUnreviewed changesPlan artifact → human review → applyUsing workspaces as environmentsShared state, shared blast radiusSeparate directories + backends per envNo cost estimation in CI$10K surprise billsInfracost or similar on every PRManual changes "just this once"Permanent driftAlways go through code, even for emergencies
This skill covers Terraform methodology and best practices. For industry-specific infrastructure patterns:
clawhub install afrexai-devops-engine — Complete DevOps & Platform Engineeringclawhub install afrexai-cybersecurity-engine — Security Hardening & Complianceclawhub install afrexai-system-architect — System Architecture Decision Frameworksclawhub install afrexai-api-architect — API Design & Lifecycle Managementclawhub install afrexai-cicd-engineering — CI/CD Pipeline EngineeringBrowse all AfrexAI skills: clawhub.com → Search "afrexai"
Storefront: afrexai-cto.github.io/context-packs