infrastructure-as-code

Use when writing Terraform for cloud resources, setting up remote state, structuring modules for reuse, managing multiple environments, reviewing a plan before apply, or importing and resolving state drift.

kid-sid/codex-spellbook1 installsMITSynced Aug 22

Works with

Claude CodeCursorCodex CLIGitHub CopilotGemini CLI
---
name: infrastructure-as-code
description: Use when writing Terraform for cloud resources, setting up remote state, structuring modules for reuse, managing multiple environments, reviewing a plan before apply, or importing and resolving state drift.
license: MIT
---

# Infrastructure as Code

Terraform lets you define, provision, and version cloud infrastructure as declarative HCL code, enabling repeatable and reviewable infrastructure changes.

## When to Activate

- Writing Terraform for cloud resources (VPC, RDS, EKS, IAM, etc.)
- Setting up a Terraform state backend
- Creating a reusable Terraform module
- Managing multiple environments (dev/staging/prod) with Terraform
- Reviewing a `terraform plan` before applying
- Dealing with state drift or importing existing resources

## Core Building Blocks

```hcl
# Provider — connects Terraform to a cloud API
terraform {
  required_version = ">= 1.7"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

provider "aws" {
  region = var.aws_region
}

# Variable — parameterise configuration
variable "aws_region" {
  type        = string
  description = "AWS region to deploy into"
  default     = "us-east-1"

  validation {
    condition     = can(regex("^[a-z]{2}-[a-z]+-[0-9]$", var.aws_region))
    error_message = "Must be a valid AWS region code."
  }
}

# Local — computed values used inside the module
locals {
  name_prefix = "${var.environment}-${var.app_name}"
  common_tags = {
    Environment = var.environment
    ManagedBy   = "terraform"
    App         = var.app_name
  }
}

# Resource — a cloud resource
resource "aws_s3_bucket" "app_data" {
  bucket = "${local.name_prefix}-app-data"
  tags   = local.common_tags
}

# Data source — read existing resource without managing it
data "aws_ami" "amazon_linux" {
  most_recent = true
  owners      = ["amazon"]
  filter {
    name   = "name"
    values = ["al2023-ami-*-x86_64"]
  }
}

# Output — export values for other modules or humans
output "s3_bucket_arn" {
  value       = aws_s3_bucket.app_data.arn
  description = "ARN of the application data bucket"
}
```

## State Management

### Remote State Backend

State must be remote and locked — never store `terraform.tfstate` in git.

**AWS (S3 + DynamoDB lock):**
```hcl
terraform {
  backend "s3" {
    bucket         = "my-org-terraform-state"
    key            = "services/payment-service/terraform.tfstate"
    region         = "us-east-1"
    encrypt        = true
    dynamodb_table = "terraform-locks"  # partition key: LockID (String)
  }
}
```

**GCP (GCS):**
```hcl
terraform {
  backend "gcs" {
    bucket = "my-org-terraform-state"
    prefix = "services/payment-service"
  }
}
```

### State Commands

```bash
terraform state list                      # list all managed resources
terraform state show aws_s3_bucket.data   # inspect a resource's state
terraform state mv OLD_ADDR NEW_ADDR      # rename without destroying
terraform state rm aws_s3_bucket.old      # remove from state (doesn't destroy)
terraform force-unlock LOCK_ID            # release a stuck lock
```

**State file security:** The state file contains sensitive values (RDS passwords, private keys). Ensure S3 bucket has:
- Versioning enabled (recover from bad apply)
- Server-side encryption
- Block public access
- Access restricted to CI role + team IAM role only

## Module Structure

```
modules/
└── rds-postgres/
    ├── main.tf        # resources
    ├── variables.tf   # inputs
    ├── outputs.tf     # outputs
    └── README.md      # usage docs (required for shared modules)
```

### Module Example

```hcl
# modules/rds-postgres/variables.tf
variable "instance_class" {
  type    = string
  default = "db.t3.medium"
}
variable "db_name"      { type = string }
variable "subnet_ids"   { type = list(string) }
variable "vpc_id"       { type = string }
variable "tags"         { type = map(string); default = {} }

# modules/rds-postgres/outputs.tf
output "endpoint"   { value = aws_db_instance.this.endpoint }
output "db_name"    { value = aws_db_instance.this.db_name }
output "secret_arn" { value = aws_secretsmanager_secret.db_password.arn }

# Consuming the module
module "payment_db" {
  source = "../../modules/rds-postgres"

  db_name        = "payments"
  instance_class = "db.t3.large"
  subnet_ids     = module.vpc.private_subnet_ids
  vpc_id         = module.vpc.vpc_id
  tags           = local.common_tags
}
```

### Module Versioning

```hcl
# Pin to a Git tag (preferred for shared modules)
module "rds" {
  source = "git::https://github.com/my-org/tf-modules.git//rds-postgres?ref=v2.1.0"
}

# Terraform Registry
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 5.0"
}
```

## Environment Strategy

### Directory-per-Environment (recommended)

```
infra/
├── modules/
│   ├── vpc/
│   └── rds-postgres/
└── environments/
    ├── dev/
    │   ├── main.tf          # calls modules
    │   ├── terraform.tfvars # dev-specific values
    │   └── backend.tf       # dev state backend
    ├── staging/
    │   └── ...
    └── prod/
        └── ...
```

Pros: complete isolation, different providers per env, easy to `cd` into.
Cons: some code duplication across environments.

### Workspace (alternative)

```bash
terraform workspace new dev
terraform workspace select staging
terraform workspace list
```

Use `terraform.workspace` in HCL:
```hcl
locals {
  instance_type = terraform.workspace == "prod" ? "db.r6g.xlarge" : "db.t3.medium"
}
```

**Decision:** Use directory-per-environment for significant infrastructure differences between envs. Use workspaces only for identical infrastructure with minor variable differences.

### tfvars per Environment

```hcl
# environments/prod/terraform.tfvars
aws_region     = "us-east-1"
environment    = "prod"
instance_class = "db.r6g.xlarge"
min_capacity   = 3
max_capacity   = 20
```

## Plan/Apply Workflow

```bash
# 1. Init (first time, or after source changes)
terraform init

# 2. Format and validate
terraform fmt -recursive
terraform validate

# 3. Plan — save output for reproducible apply
terraform plan -out=tfplan -var-file=terraform.tfvars

# 4. Policy check (optional, using OPA/Conftest)
terraform show -json tfplan | conftest test -

# 5. Apply from the saved plan (no re-planning)
terraform apply tfplan

# 6. Verify
terraform state list
```

### CI Pipeline Integration

```yaml
# .github/workflows/terraform.yml
jobs:
  plan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: "1.7.0"
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::123456789:role/terraform-plan
          aws-region: us-east-1
      - run: terraform init
      - run: terraform plan -out=tfplan
      - run: terraform show -json tfplan > tfplan.json
      - name: Policy check
        run: conftest test tfplan.json --policy policies/
      - uses: actions/upload-artifact@v4
        with:
          name: tfplan
          path: tfplan

  apply:
    needs: plan
    if: github.ref == 'refs/heads/main'
    environment: production   # requires approval in GitHub
    steps:
      - uses: actions/download-artifact@v4
        with: { name: tfplan }
      - run: terraform apply tfplan
```

## Common Resource Patterns

### VPC Networking (AWS)

```hcl
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 5.0"

  name = "${local.name_prefix}-vpc"
  cidr = "10.0.0.0/16"

  azs              = ["us-east-1a", "us-east-1b", "us-east-1c"]
  private_subnets  = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
  public_subnets   = ["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"]

  enable_nat_gateway = true
  single_nat_gateway = var.environment != "prod"  # save cost in non-prod

  tags = local.common_tags
}
```

### IAM Role + Policy (least privilege)

```hcl
resource "aws_iam_role" "app_role" {
  name = "${local.name_prefix}-app"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { Service = "ec2.amazonaws.com" }
      Action    = "sts:AssumeRole"
    }]
  })
  tags = local.common_tags
}

resource "aws_iam_role_policy" "app_policy" {
  name = "app-policy"
  role = aws_iam_role.app_role.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect   = "Allow"
        Action   = ["s3:GetObject", "s3:PutObject"]
        Resource = "${aws_s3_bucket.app_data.arn}/*"
      },
      {
        Effect   = "Allow"
        Action   = ["secretsmanager:GetSecretValue"]
        Resource = aws_secretsmanager_secret.db_password.arn
      }
    ]
  })
}
```

### RDS Instance

```hcl
resource "aws_db_instance" "postgres" {
  identifier        = "${local.name_prefix}-postgres"
  engine            = "postgres"
  engine_version    = "16.2"
  instance_class    = var.db_instance_class
  allocated_storage = 100
  storage_encrypted = true

  db_name  = var.db_name
  username = "app"
  password = random_password.db.result

  db_subnet_group_name   = aws_db_subnet_group.this.name
  vpc_security_group_ids = [aws_security_group.rds.id]

  backup_retention_period = var.environment == "prod" ? 30 : 7
  deletion_protection     = var.environment == "prod"
  skip_final_snapshot     = var.environment != "prod"

  tags = local.common_tags
}
```

## Drift Detection and Import

### Detect Drift

```bash
terraform plan   # shows resources that diverged from state
```

Any `~` (update) or `-`/`+` (replace) on a resource you haven't changed = drift.

### Import Existing Resources

```bash
# Import by resource address and cloud ID
terraform import aws_s3_bucket.legacy my-existing-bucket

# After import, write matching HCL or Terraform will show a diff
```

### `moved` Block (safe refactoring)

```hcl
# Rename a resource without destroying it
moved {
  from = aws_s3_bucket.data
  to   = aws_s3_bucket.app_data
}
```

> See also: `ci-cd`, `containerization`, `security`

## Red Flags

- **Storing `terraform.tfstate` in git** — state files contain plaintext secrets (RDS passwords, private keys); use an S3+DynamoDB or GCS backend with server-side encryption from day one
- **Running `terraform apply` directly without a saved plan** — `terraform apply` without `-out=tfplan` re-plans at apply time; what was reviewed in the PR and what actually runs can differ if state changed between plan and apply
- **Using `terraform apply -auto-approve` in CI on the production environment** — auto-approve bypasses the human gate; production applies must require explicit approval via a GitHub environment protection rule
- **Module pinned to `main` or with no version constraint** — `source = "git::...?ref=main"` means any upstream commit silently changes your infrastructure; pin to a specific git tag or Terraform registry version
- **IAM policy with `"Action": "*"` or `"Resource": "*"`** — wildcard actions on all resources violates least privilege; scope to the exact actions and resource ARNs the role actually needs
- **`terraform state rm` used to "fix" a drift problem** — removing a resource from state without destroying it creates orphaned cloud resources that accumulate cost and may introduce security gaps; use `moved` blocks or `terraform import` instead
- **Deleting a Terraform resource block to decommission a resource** — removing the block from HCL causes `terraform plan` to show a destroy; validate intent with `terraform plan` and add `lifecycle { prevent_destroy = true }` on stateful resources
- **Sharing a single state file across all environments** — one bad apply in staging can corrupt or lock the production state; each environment must have its own state file with its own backend key

## Checklist

- [ ] Remote state backend configured (S3+DynamoDB or GCS) — state never committed to git
- [ ] State S3 bucket has versioning, encryption, and public access blocked
- [ ] All resources tagged with `environment`, `app`, and `managed_by = "terraform"`
- [ ] Infrastructure split into reusable modules with `variables.tf` and `outputs.tf`
- [ ] Modules pinned to specific versions (git tag or registry version constraint)
- [ ] `terraform plan -out=tfplan` used — apply from saved plan, not a re-plan
- [ ] Policy checks (OPA/Conftest) run on plan JSON before apply
- [ ] Production apply requires human approval (GitHub environment protection)
- [ ] IAM roles follow least privilege — no `"*"` actions or resources in policy
- [ ] Deletion protection enabled on RDS and other stateful prod resources
- [ ] `moved` blocks used for resource renames — never destroy-and-recreate
- [ ] `terraform plan` run after every manual change to detect drift

More DevOps & Infrastructure skills

← All DevOps & Infrastructure skills

Check your AI visibility

One URL in, a 0–100 score and the exact fixes out.

RUN THE CHECK

Browse all the tools

15 tools across six categories
13 of them never send your data anywhere

Free · No signup · No trial clock

SEE THE DIRECTORY