Skip to main content
Terraform·9 min read

Terraform Modules — Ending the Copy-Paste Across 6 Repos Problem

Design production-grade Terraform modules with proper structure, input validation, versioning strategy, and composition patterns. Covers module registries, testing, and common architectural patterns.

DT

DevOps Engineer & Technical Writer

The Problem

Your Terraform codebase has grown to 3000 lines in a single directory. Every team copies and pastes VPC configurations. When a security policy changes, you update the same resource block in 15 places. Terraform modules encapsulate reusable patterns, but poorly designed modules create worse problems than no modules at all.

TERRAFORM MODULE COMPOSITION Root Module main.tf / variables.tf / outputs.tf module call module call MODULE Networking VPC, subnets, routes outputs: vpc_id, subnet_ids MODULE Compute EC2, ASG, ALB outputs: lb_dns, asg_name MODULE Database RDS, ElastiCache outputs: db_endpoint MODULE Monitoring CloudWatch, SNS outputs: alarm_arns vpc_id, subnet_ids vpc_id, subnet_ids consumed by database Outputs from one module become inputs for another - creating a dependency graph

Module Structure

modules/vpc/

├── main.tf # Core resources

├── variables.tf # Input variables with validation

├── outputs.tf # Output values

├── versions.tf # Provider version constraints

├── locals.tf # Computed local values

└── README.md # Usage documentation

versions.tf

terraform {

required_version = ">= 1.5.0"

required_providers {

aws = {

source = "hashicorp/aws"

version = ">= 5.0, < 6.0"

}

}

}

variables.tf with validation

variable "environment" {

description = "Deployment environment"

type = string

validation {

condition = contains(["dev", "staging", "production"], var.environment)

error_message = "Environment must be dev, staging, or production."

}

}

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 "availability_zones" {

description = "List of availability zones"

type = list(string)

validation {

condition = length(var.availability_zones) >= 2

error_message = "At least 2 AZs required for high availability."

}

}

variable "tags" {

description = "Tags to apply to all resources"

type = map(string)

default = {}

}

outputs.tf

output "vpc_id" {

description = "The ID of the VPC"

value = aws_vpc.main.id

}

output "public_subnet_ids" {

description = "List of public subnet IDs"

value = aws_subnet.public[*].id

}

output "private_subnet_ids" {

description = "List of private subnet IDs"

value = aws_subnet.private[*].id

}

Calling Modules

Local modules

module "vpc" {

source = "../../modules/vpc"

environment = "production"

vpc_cidr = "10.0.0.0/16"

availability_zones = ["us-east-1a", "us-east-1b", "us-east-1c"]

tags = {

Project = "payment-platform"

}

}

module "api_service" {

source = "../../modules/ecs-service"

service_name = "payment-api"

vpc_id = module.vpc.vpc_id

subnet_ids = module.vpc.private_subnet_ids

container_image = "123456789.dkr.ecr.us-east-1.amazonaws.com/payment-api:v2.3.1"

cpu = 512

memory = 1024

}

Registry modules with version pinning

module "vpc" {

source = "terraform-aws-modules/vpc/aws"

version = "5.5.1"

name = "production-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

}

Git repository modules

module "vpc" {

source = "git::https://github.com/company/terraform-modules.git//modules/vpc?ref=v2.1.0"

}

Versioning Strategy

Semantic versioning

v1.0.0 -> v1.1.0  (new feature, backward compatible)

v1.1.0 -> v1.2.0 (new optional variable added)

v1.2.0 -> v2.0.0 (breaking change: renamed variable)

Git tags

git tag -a v2.1.0 -m "Add multi-AZ NAT gateway support"

git push origin v2.1.0

Version constraints

module "vpc" {

source = "app.terraform.io/company/vpc/aws"

version = "~> 2.1" # Allows 2.1.x but not 2.2.0

}

Composition Patterns

Root module composition

environments/

├── production/

│ ├── main.tf

│ ├── terraform.tfvars

│ └── backend.tf

├── staging/

│ ├── main.tf

│ ├── terraform.tfvars

│ └── backend.tf

└── modules/

├── vpc/

├── ecs-service/

└── rds/

Wrapper modules with company defaults

module "vpc" {

source = "terraform-aws-modules/vpc/aws"

version = "5.5.1"

name = "${var.project}-${var.environment}"

cidr = var.vpc_cidr

# Company defaults

enable_nat_gateway = true

single_nat_gateway = var.environment != "production"

enable_dns_hostnames = true

enable_flow_log = true

tags = merge(var.tags, {

Environment = var.environment

ManagedBy = "terraform"

})

}

Feature toggles

variable "enable_monitoring" {

type = bool

default = true

}

resource "aws_cloudwatch_metric_alarm" "cpu_high" {

count = var.enable_monitoring ? 1 : 0

alarm_name = "${var.service_name}-cpu-high"

comparison_operator = "GreaterThanThreshold"

threshold = 80

evaluation_periods = 2

metric_name = "CPUUtilization"

namespace = "AWS/ECS"

period = 300

statistic = "Average"

}

Module Design Principles

Use locals for computed values

locals {

name_prefix = "${var.project}-${var.environment}"

private_subnet_cidrs = [

for i, az in var.availability_zones :

cidrsubnet(var.vpc_cidr, 8, i)

]

common_tags = merge(var.tags, {

Environment = var.environment

ManagedBy = "terraform"

})

}

Right level of abstraction

# TOO LOW — consumer needs to know implementation details

variable "subnet_route_table_association_count" {}

# TOO HIGH — no flexibility at all

# (hardcoded CIDR and subnet count)

# JUST RIGHT

variable "vpc_cidr" { default = "10.0.0.0/16" }

variable "availability_zones" {} # Force explicit choice

Testing Modules

# Validate syntax

cd modules/vpc/examples/simple

terraform init

terraform validate

# Plan to check resource count

terraform plan -out=plan.tfplan

terraform show -json plan.tfplan | jq '.resource_changes | length'

Common Mistakes

  • Modularizing too early — Do not create modules until you have 2-3 consumers. Premature abstraction creates rigid interfaces.
  • Passing provider configuration into modules — Let modules inherit providers. Only pass explicitly for multi-region scenarios.
  • Using count with modules based on variables — Changing count reindexes instances and recreates resources. Use for_each with maps.
  • Not pinning module versionsref=main means any commit can break your infrastructure. Always pin to a tag.
  • Outputting too much or too little — Output everything downstream might need (IDs, ARNs), not internal details.
  • Deeply nested modules — Module calling module calling module makes debugging painful. Max 2 levels of nesting.
  • Quick Reference

    PatternWhen to Use
    Local moduleTeam-internal shared patterns
    Git ref moduleCross-team shared modules
    Registry moduleCommunity modules with pinned version
    Wrapper moduleAdd company defaults to community modules
    Feature togglesOptional features within a module
    CompositionRoot modules calling focused children

    Summary

    Good modules are focused, validated, versioned, and composed at the root level. Start simple, add validation rules, version with semantic tags, and test with example configurations. The goal is infrastructure patterns teams can adopt confidently without reading the implementation.

    ---

    Frequently Asked Questions

    What is a Terraform module and when should I create one?

    A Terraform module is a reusable collection of resources with configurable inputs (variables) and outputs. Create a module when you have a set of resources that are deployed together in a repeated pattern (e.g., VPC + subnets + route tables). Don't over-modularize — if it's used in only one place, keep it inline until you need reuse.

    How do I structure a Terraform module?

    A module needs at minimum: main.tf (resources), variables.tf (inputs), outputs.tf (exports), and README.md (documentation). Add versions.tf for provider version constraints. Keep modules focused on one concern — a "networking" module shouldn't also create compute resources. Use descriptive variable names with validation blocks and sensible defaults.

    What is the difference between root modules and child modules?

    The root module is your top-level working directory where you run terraform apply — it contains your backend config and calls other modules. Child modules are reusable components called with module "name" { source = "./modules/vpc" }. Root modules are environment-specific (dev, prod), while child modules are environment-agnostic and parameterized through variables.

    How do I version and share Terraform modules?

    Use Git tags for versioning (v1.0.0) and reference modules with source = "git::https://github.com/org/module.git?ref=v1.0.0". For organizations, publish to a private Terraform registry or use Git SSH sources. Pin module versions in calling code and use semantic versioning to communicate breaking changes. Never reference main branch in production.

    How do I pass data between Terraform modules?

    Define outputs in the source module (output "vpc_id" { value = aws_vpc.main.id }) and reference them in the calling module with module.vpc.vpc_id. For modules that don't directly call each other, use terraform_remote_state data source to read outputs from another state file, or pass values through the root module as variables.

    ---