Files
claude-skills-reference/engineering/terraform-patterns/references/module-patterns.md
2026-03-15 23:29:01 +01:00

9.7 KiB

Terraform Module Design Patterns Reference

Pattern 1: Flat Module (Single Directory)

Best for: Small projects, < 20 resources, single team ownership.

project/
├── main.tf
├── variables.tf
├── outputs.tf
├── versions.tf
├── locals.tf
├── backend.tf
└── terraform.tfvars

Example: Simple VPC + EC2

# versions.tf
terraform {
  required_version = ">= 1.5.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

# locals.tf
locals {
  name_prefix = "${var.project}-${var.environment}"
  common_tags = {
    Project     = var.project
    Environment = var.environment
    ManagedBy   = "terraform"
  }
}

# main.tf
resource "aws_vpc" "main" {
  cidr_block           = var.vpc_cidr
  enable_dns_hostnames = true
  enable_dns_support   = true

  tags = merge(local.common_tags, {
    Name = "${local.name_prefix}-vpc"
  })
}

resource "aws_subnet" "public" {
  count             = length(var.public_subnet_cidrs)
  vpc_id            = aws_vpc.main.id
  cidr_block        = var.public_subnet_cidrs[count.index]
  availability_zone = var.availability_zones[count.index]

  tags = merge(local.common_tags, {
    Name = "${local.name_prefix}-public-${count.index + 1}"
    Tier = "public"
  })
}

# variables.tf
variable "project" {
  description = "Project name used for resource naming"
  type        = string
}

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 "vpc_cidr" {
  description = "CIDR block for the VPC"
  type        = string
  default     = "10.0.0.0/16"
  validation {
    condition     = can(cidrhost(var.vpc_cidr, 0))
    error_message = "Must be a valid CIDR block."
  }
}

variable "public_subnet_cidrs" {
  description = "CIDR blocks for public subnets"
  type        = list(string)
  default     = ["10.0.1.0/24", "10.0.2.0/24"]
}

variable "availability_zones" {
  description = "AZs for subnet placement"
  type        = list(string)
  default     = ["us-east-1a", "us-east-1b"]
}

# outputs.tf
output "vpc_id" {
  description = "ID of the created VPC"
  value       = aws_vpc.main.id
}

output "public_subnet_ids" {
  description = "IDs of public subnets"
  value       = aws_subnet.public[*].id
}

Pattern 2: Nested Modules (Composition)

Best for: Multiple environments, shared patterns, team collaboration.

infrastructure/
├── environments/
│   ├── dev/
│   │   ├── main.tf
│   │   ├── backend.tf
│   │   └── terraform.tfvars
│   ├── staging/
│   │   └── ...
│   └── prod/
│       └── ...
└── modules/
    ├── networking/
    │   ├── main.tf
    │   ├── variables.tf
    │   └── outputs.tf
    ├── compute/
    │   └── ...
    └── database/
        └── ...

Root Module (environments/dev/main.tf)

module "networking" {
  source = "../../modules/networking"

  project              = var.project
  environment          = "dev"
  vpc_cidr             = "10.0.0.0/16"
  public_subnet_cidrs  = ["10.0.1.0/24", "10.0.2.0/24"]
  private_subnet_cidrs = ["10.0.10.0/24", "10.0.11.0/24"]
}

module "compute" {
  source = "../../modules/compute"

  project        = var.project
  environment    = "dev"
  vpc_id         = module.networking.vpc_id
  subnet_ids     = module.networking.private_subnet_ids
  instance_type  = "t3.micro"
  instance_count = 1
}

module "database" {
  source = "../../modules/database"

  project            = var.project
  environment        = "dev"
  vpc_id             = module.networking.vpc_id
  subnet_ids         = module.networking.private_subnet_ids
  instance_class     = "db.t3.micro"
  allocated_storage  = 20
  db_password        = var.db_password
}

Key Rules

  • Child modules never call other child modules
  • Pass values explicitly — no hidden data source lookups in children
  • Provider configuration only in root module
  • Each module has its own variables.tf, outputs.tf, main.tf

Pattern 3: Registry Module Pattern

Best for: Reusable modules shared across teams or organizations.

terraform-aws-vpc/
├── main.tf
├── variables.tf
├── outputs.tf
├── versions.tf
├── README.md
├── examples/
│   ├── simple/
│   │   └── main.tf
│   └── complete/
│       └── main.tf
└── modules/
    ├── subnet/
    │   ├── main.tf
    │   ├── variables.tf
    │   └── outputs.tf
    └── nat-gateway/
        └── ...

Publishing Conventions

# Consumer usage
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 5.0"

  name = "my-vpc"
  cidr = "10.0.0.0/16"

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

  enable_nat_gateway = true
  single_nat_gateway = true
}

Registry Module Requirements

  • Repository named terraform-<PROVIDER>-<NAME>
  • README.md with usage examples
  • Semantic versioning via git tags
  • examples/ directory with working configurations
  • No provider configuration in the module itself

Pattern 4: Mono-Repo with Workspaces

Best for: Teams that prefer single-repo with workspace-based isolation.

# backend.tf
terraform {
  backend "s3" {
    bucket         = "my-terraform-state"
    key            = "project/terraform.tfstate"
    region         = "us-east-1"
    dynamodb_table = "terraform-locks"
    encrypt        = true
  }
}

# main.tf
locals {
  env_config = {
    dev = {
      instance_type = "t3.micro"
      instance_count = 1
      db_class = "db.t3.micro"
    }
    staging = {
      instance_type = "t3.small"
      instance_count = 2
      db_class = "db.t3.small"
    }
    prod = {
      instance_type = "t3.large"
      instance_count = 3
      db_class = "db.r5.large"
    }
  }
  config = local.env_config[terraform.workspace]
}

Usage

terraform workspace new dev
terraform workspace new staging
terraform workspace new prod

terraform workspace select dev
terraform apply

terraform workspace select prod
terraform apply

Workspace Caveats

  • All environments share the same backend — less isolation than separate directories
  • A mistake in the code affects all environments
  • Can't have different provider versions per workspace
  • Recommended only for simple setups; prefer separate directories for production

Pattern 5: for_each vs count

Use count for identical resources

resource "aws_subnet" "public" {
  count             = 3
  vpc_id            = aws_vpc.main.id
  cidr_block        = cidrsubnet(var.vpc_cidr, 8, count.index)
  availability_zone = data.aws_availability_zones.available.names[count.index]
}

Use for_each for distinct resources

variable "buckets" {
  type = map(object({
    versioning = bool
    lifecycle_days = number
  }))
  default = {
    logs    = { versioning = false, lifecycle_days = 30 }
    backups = { versioning = true,  lifecycle_days = 90 }
    assets  = { versioning = true,  lifecycle_days = 0 }
  }
}

resource "aws_s3_bucket" "this" {
  for_each = var.buckets
  bucket   = "${var.project}-${each.key}"
}

resource "aws_s3_bucket_versioning" "this" {
  for_each = { for k, v in var.buckets : k => v if v.versioning }
  bucket   = aws_s3_bucket.this[each.key].id

  versioning_configuration {
    status = "Enabled"
  }
}

Why for_each > count

  • count uses index — removing item 0 shifts all others, causing destroy/recreate
  • for_each uses keys — removing a key only affects that resource
  • Use count only for identical resources where order doesn't matter

Variable Design Patterns

variable "database" {
  description = "Database configuration"
  type = object({
    engine         = string
    instance_class = string
    storage_gb     = number
    multi_az       = bool
    backup_days    = number
  })
  default = {
    engine         = "postgres"
    instance_class = "db.t3.micro"
    storage_gb     = 20
    multi_az       = false
    backup_days    = 7
  }
}

Validation Blocks

variable "instance_type" {
  description = "EC2 instance type"
  type        = string

  validation {
    condition     = can(regex("^t[23]\\.", var.instance_type))
    error_message = "Only t2 or t3 instance types are allowed."
  }
}

variable "cidr_block" {
  description = "VPC CIDR block"
  type        = string

  validation {
    condition     = can(cidrhost(var.cidr_block, 0))
    error_message = "Must be a valid IPv4 CIDR block."
  }
}

Anti-Patterns to Avoid

Anti-Pattern Problem Solution
God module (100+ resources) Impossible to reason about, slow plan/apply Split into focused child modules
Circular module dependencies Terraform can't resolve dependency graph Flatten or restructure module boundaries
Data sources in child modules Hidden dependencies, hard to test Pass values as variables from root module
Provider config in child modules Can't reuse module across accounts/regions Configure providers in root only
Hardcoded values Not reusable across environments Use variables with defaults and validation
No outputs Consumer modules can't reference resources Output IDs, ARNs, endpoints
No variable descriptions Users don't know what to provide Every variable gets a description
terraform.tfvars committed Secrets leak to version control Use .gitignore, env vars, or Vault