| 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. |
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
# 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):
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):
terraform {
backend "gcs" {
bucket = "my-org-terraform-state"
prefix = "services/payment-service"
}
}
State Commands
terraform state list
terraform state show aws_s3_bucket.data
terraform state mv OLD_ADDR NEW_ADDR
terraform state rm aws_s3_bucket.old
terraform force-unlock LOCK_ID
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
# 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
# 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)
terraform workspace new dev
terraform workspace select staging
terraform workspace list
Use terraform.workspace in 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
# 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
terraform init
terraform fmt -recursive
terraform validate
terraform plan -out=tfplan -var-file=terraform.tfvars
terraform show -json tfplan | conftest test -
terraform apply tfplan
terraform state list
CI Pipeline Integration
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:
{ }
Common Resource Patterns
VPC Networking (AWS)
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)
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
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
terraform plan
Any ~ (update) or -/+ (replace) on a resource you haven't changed = drift.
Import Existing Resources
terraform import aws_s3_bucket.legacy my-existing-bucket
moved Block (safe refactoring)
# 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