Skip to main content
Back to Blog
Cloud Automation

Terraform Module Patterns: How I Structure IaC for Reuse

March 18, 202611 min read
TerraformAWSIaCInfrastructureDevOpsHCL

Terraform Module Patterns: How I Structure IaC for Reuse

After building the AWS Landing Zone and multiple infrastructure projects, I've developed opinions about how to write Terraform modules that other people can actually use.

The Module Structure

Every module follows this structure:

modules/
└── vpc/
    ├── main.tf          # Primary resources
    ├── variables.tf     # Input variables with descriptions
    ├── outputs.tf       # Output values
    ├── versions.tf      # Provider version constraints
    ├── locals.tf        # Computed local values
    ├── data.tf          # Data sources
    ├── README.md        # Usage examples
    └── examples/
        └── basic/
            └── main.tf  # Working example

Key rules:

  • One module = one logical resource group (VPC, ECS service, S3 bucket + policy)
  • No module should be more than 200 lines of HCL
  • Every variable has a description AND a type constraint
  • Every output has a description

Variable Naming Convention

I prefix variables by purpose:

# Naming: enable_* for feature flags
variable "enable_flow_logs" {
  description = "Enable VPC flow logs to CloudWatch"
  type        = bool
  default     = true
}

# Naming: *_name for naming resources
variable "vpc_name" {
  description = "Name tag for the VPC and related resources"
  type        = string
}

# Naming: *_cidr for network blocks
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."
  }
}

# Naming: tags for common tags (always last)
variable "tags" {
  description = "Common tags applied to all resources"
  type        = map(string)
  default     = {}
}

The Output Contract

Outputs are the API of your module. I treat them like a public interface — they don't change once published:

# Every module outputs its primary resource ID
output "vpc_id" {
  description = "The ID of the VPC"
  value       = aws_vpc.main.id
}

# And any ARNs needed for IAM policies
output "vpc_arn" {
  description = "The ARN of the VPC"
  value       = aws_vpc.main.arn
}

# And any values other modules might need
output "private_subnet_ids" {
  description = "List of private subnet IDs"
  value       = aws_subnet.private[*].id
}

Tags: The Non-Negotiable

Every resource gets tags. No exceptions:

locals {
  common_tags = merge(var.tags, {
    ManagedBy   = "terraform"
    Module      = "vpc"
    Environment = var.environment
  })
}

Tags enable cost tracking, access control, and operational visibility. Untagged resources in a shared AWS account are a liability.

The Anti-Patterns

Don't hardcode regions. Use data sources:

data "aws_region" "current" {}
data "aws_caller_identity" "current" {}

Don't use count for complex logic. Use for_each with a map:

# Bad: count = var.enable_thing ? 1 : 0
# Good: for_each with explicit keys
variable "subnets" {
  type = map(object({
    cidr = string
    az   = string
  }))
}

Don't put secrets in Terraform state. Use AWS Secrets Manager or SSM Parameter Store and reference them as data sources.

What I'd Do Differently

  1. Adopt OpenTofu for new projects. The license change makes Terraform a business risk for open-source modules.
  2. Use Terragrunt earlier. Managing multiple environments with raw Terraform workspaces is painful. Terragrunt's folder structure is cleaner.
  3. Write Terratest tests from day one. I added testing retroactively. Starting with tests prevents "it works on my machine" drift.

Good IaC is boring. It should be predictable, documented, and tested — just like good application code.

Want to see this in action?

Check out the projects and case studies behind these articles.