4 min read
On this page

Modules & Patterns

Terraform modules are reusable packages of configuration. Instead of copying and pasting the same VPC setup, database configuration, or compute instance block across every project, you write it once as a module and call it wherever you need it. Modules are the primary mechanism for code reuse, abstraction, and consistency in Terraform.

What a Module Is

Every Terraform configuration is technically a module. The directory you run terraform apply in is the root module. When that root module calls another directory of Terraform files, it is using a child module.

Project structure:
  infrastructure/
    production/
      main.tf          <- Root module
      variables.tf
      outputs.tf
    modules/
      vpc/
        main.tf        <- Child module
        variables.tf
        outputs.tf
      rds/
        main.tf        <- Child module
        variables.tf
        outputs.tf

The root module calls child modules:

# infrastructure/production/main.tf

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

  environment = "production"
  cidr_block  = "10.0.0.0/16"
}

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

  environment    = "production"
  vpc_id         = module.vpc.vpc_id
  subnet_ids     = module.vpc.private_subnet_ids
  instance_class = "db.r6g.large"
}

Module Structure

A well-structured module has three files at minimum:

modules/vpc/
  main.tf           Resources and data sources
  variables.tf      Input variables (the module's API)
  outputs.tf        Output values (what the module exposes)
  README.md         Usage documentation (optional but recommended)
  versions.tf       Provider and Terraform version constraints

variables.tf: The Module's Input API

# modules/vpc/variables.tf

variable "environment" {
  description = "Environment name (e.g., production, staging)"
  type        = string
}

variable "cidr_block" {
  description = "CIDR block for the VPC"
  type        = string
  default     = "10.0.0.0/16"
}

variable "availability_zones" {
  description = "List of availability zones"
  type        = list(string)
  default     = ["us-east-1a", "us-east-1b", "us-east-1c"]
}

variable "enable_nat_gateway" {
  description = "Whether to create a NAT gateway for private subnets"
  type        = bool
  default     = true
}

main.tf: The Resources

# modules/vpc/main.tf

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

  tags = {
    Name        = "${var.environment}-vpc"
    Environment = var.environment
  }
}

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

  map_public_ip_on_launch = true

  tags = {
    Name        = "${var.environment}-public-${var.availability_zones[count.index]}"
    Environment = var.environment
  }
}

resource "aws_subnet" "private" {
  count             = length(var.availability_zones)
  vpc_id            = aws_vpc.main.id
  cidr_block        = cidrsubnet(var.cidr_block, 8, count.index + length(var.availability_zones))
  availability_zone = var.availability_zones[count.index]

  tags = {
    Name        = "${var.environment}-private-${var.availability_zones[count.index]}"
    Environment = var.environment
  }
}

outputs.tf: The Module's Output API

# modules/vpc/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"
  cidr_block  = "10.0.0.0/16"
}

Remote Modules from the Terraform Registry

The Terraform Registry hosts community and official modules. They follow a standardized structure and are versioned.

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
  single_nat_gateway = true
}

Git Repositories

module "vpc" {
  source = "git::https://github.com/myorg/terraform-modules.git//vpc?ref=v1.2.0"

  environment = "production"
}

Always pin module versions. Without a version constraint, terraform init pulls the latest, which may contain breaking changes.

Composition: Root Module Calls Child Modules

A well-designed infrastructure uses the root module as an orchestrator that wires child modules together:

# infrastructure/production/main.tf

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

  environment        = var.environment
  cidr_block         = "10.0.0.0/16"
  availability_zones = ["us-east-1a", "us-east-1b", "us-east-1c"]
}

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

  environment    = var.environment
  vpc_id         = module.vpc.vpc_id
  subnet_ids     = module.vpc.private_subnet_ids
  instance_class = "db.r6g.large"
  engine_version = "16.1"
}

module "cache" {
  source = "../modules/elasticache"

  environment = var.environment
  vpc_id      = module.vpc.vpc_id
  subnet_ids  = module.vpc.private_subnet_ids
  node_type   = "cache.t4g.medium"
}

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

  environment   = var.environment
  vpc_id        = module.vpc.vpc_id
  subnet_ids    = module.vpc.private_subnet_ids
  database_url  = module.database.connection_string
  redis_url     = module.cache.connection_string
}

The root module contains no resource blocks -- it only calls modules and wires their outputs to each other's inputs. This makes the high-level architecture readable at a glance.

The Module Hierarchy

Root module (production/main.tf)
  ├── module "vpc"          Networking: VPC, subnets, NAT, route tables
  ├── module "database"     Data: RDS instance, security groups, parameter groups
  ├── module "cache"        Data: ElastiCache, security groups
  ├── module "ecs"          Compute: ECS cluster, task definitions, services
  ├── module "monitoring"   Observability: CloudWatch dashboards, alarms
  └── module "dns"          Networking: Route53 records, ACM certificates

Each module encapsulates one concern. Modules communicate through explicit inputs (variables) and outputs. There are no hidden dependencies.

Naming Conventions

Consistent naming prevents confusion across teams:

Module naming:
  modules/vpc/             Not: modules/network/, modules/networking/
  modules/rds/             Not: modules/database/, modules/db/
  modules/ecs/             Not: modules/compute/, modules/containers/

Resource naming within modules:
  resource "aws_vpc" "main"          Not: "this", "default", "vpc"
  resource "aws_subnet" "public"     Not: "pub_subnet", "subnet_1"
  resource "aws_subnet" "private"    Not: "priv", "internal"

Variable naming:
  variable "environment"             Not: "env", "stage"
  variable "instance_type"           Not: "type", "size"
  variable "enable_nat_gateway"      Not: "nat", "create_nat"

Use snake_case for everything. Prefix boolean variables with enable_ or create_. Use descriptive names even if they are longer.

When to Modularize

Not everything needs to be a module. Premature modularization creates indirection without benefit.

Modularize When

  • The same pattern appears in two or more places (DRY principle)
  • A group of resources always appears together (VPC + subnets + route tables)
  • You want to enforce standards across teams (the module encodes best practices)
  • The configuration is large enough that reading it as one flat file is painful

Keep It Flat When

  • You have one environment with no plans for more
  • The configuration is small (under 200 lines)
  • The team is small and everyone understands the full config
  • Modularizing would create modules with a single resource (over-abstraction)
Too flat:
  main.tf with 500 lines, 30 resources, VPC + database + compute + monitoring
  -> Hard to read, hard to review, changes to monitoring affect database plan

Too modular:
  modules/security_group/    (wraps a single aws_security_group resource)
  modules/iam_role/          (wraps a single aws_iam_role resource)
  -> Adds indirection without reducing complexity

Right level:
  modules/vpc/               (VPC + subnets + NAT + route tables: always together)
  modules/rds/               (RDS + security groups + parameter groups: always together)
  modules/ecs/               (Cluster + task definitions + services: always together)

Sharing Modules Across Teams

For larger organizations, modules can be published to a private registry or a shared Git repository:

Option 1: Terraform Cloud/Enterprise private registry
  - Versioned modules
  - Discovery via UI
  - Access control per workspace

Option 2: Git monorepo
  terraform-modules/
    modules/
      vpc/
      rds/
      ecs/
  - Referenced via Git URL with ref tag
  - Versioned via Git tags

Option 3: Git per module
  terraform-module-vpc      (one repo per module)
  terraform-module-rds
  terraform-module-ecs
  - More overhead, but cleaner versioning

Regardless of the approach, every shared module must:

  1. Have clear documentation (what it does, what variables it accepts, what it outputs)
  2. Be versioned with semantic versioning
  3. Have at least basic validation on input variables
  4. Be tested (terraform validate at minimum, automated plan/apply tests ideally)

Common Pitfalls

  • Premature modularization -- Extracting a module for a pattern you use once adds complexity without benefit. Wait until you have actual duplication.
  • Tightly coupled modules -- Modules that directly reference resources in other modules (instead of passing values through variables and outputs) are fragile and hard to reuse.
  • Unpinned module versions -- Using source = "../modules/vpc" without a version means any change to the module immediately affects all consumers. Pin versions for production.
  • Overly complex module interfaces -- A module with 40 input variables is hard to use. If the interface is too complex, the module is doing too much. Split it.
  • No documentation -- A module without a README explaining its purpose, required variables, and example usage will be misused or avoided.
  • Wrapping single resources -- A module that wraps a single resource without adding logic or defaults is just indirection. Use the resource directly.

Key Takeaways

  • Modules are Terraform's mechanism for reuse, abstraction, and consistency
  • A module has a clear interface: input variables, resources, and output values
  • The root module orchestrates child modules by wiring outputs to inputs
  • Pin module versions in production to prevent unexpected changes
  • Modularize when patterns repeat or configurations grow too large to read as flat files; keep it flat for small, single-use configurations
  • Name things consistently with snake_case, descriptive names, and enable_ prefixes for booleans
  • Shared modules must be versioned, documented, and validated