Skip to main content

Terraform Modules Best Practices: Write Reusable Infrastructure Code

Key Takeaway

Terraform module best practices for 2026. Learn module structure, versioning, composition, testing, when to create modules, and when not to over-abstract.

Table of Contents

Modules are Terraform’s unit of reuse — a directory of .tf files you can call from multiple places. Good modules save hours. Bad modules create more problems than they solve. Here’s how to build modules that work.

Standard Module Structure

modules/
└── vpc/
    ├── main.tf           # Resources
    ├── variables.tf      # Input variables
    ├── outputs.tf        # Output values
    ├── versions.tf       # Provider and Terraform version constraints
    ├── README.md         # Documentation
    └── examples/
        └── basic/
            └── main.tf   # Usage example

versions.tf

terraform {
  required_version = ">= 1.5.0"

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

Don’t pin exact provider versions in modules — let the caller decide:

# ❌ Too restrictive — breaks when caller uses 5.50
version = "= 5.40.0"

# ✅ Minimum version with flexibility
version = ">= 5.0"

When to Create a Module

Create a module when:

  • You use the same pattern 3+ times (DRY principle)
  • A logical group of resources always deploys together (VPC + subnets + routing)
  • You want to enforce standards (all S3 buckets must have encryption)
  • Different teams need the same infrastructure with different parameters

Don’t create a module when:

  • It wraps a single resource with no added value
  • It’s used only once — inline is simpler
  • It hides important configuration that the caller should control
# ❌ Pointless wrapper — just use the resource directly
module "bucket" {
  source = "./modules/s3-bucket"
  name   = "my-bucket"
}

# modules/s3-bucket/main.tf
resource "aws_s3_bucket" "this" {
  bucket = var.name
}

# ✅ Useful module — bundles encryption, versioning, lifecycle, and policy
module "bucket" {
  source = "./modules/secure-bucket"
  name   = "my-bucket"
  retention_days = 90
}

Module Design Principles

1. Opinionated Defaults, Flexible Overrides

# modules/ecs-service/variables.tf

variable "name" {
  type        = string
  description = "Service name"
}

# Opinionated default — most services need this
variable "health_check_grace_period" {
  type    = number
  default = 60
}

# Opinionated default — production-ready
variable "enable_container_insights" {
  type    = bool
  default = true
}

# Flexible — caller decides
variable "desired_count" {
  type        = number
  description = "Number of tasks"
  # No default — caller must think about this
}

2. Don’t Create Provider Blocks

# ❌ Module creates its own provider — breaks multi-region
# modules/vpc/main.tf
provider "aws" {
  region = var.region
}

# ✅ Module inherits provider from caller
# The caller passes the provider
module "vpc_us" {
  source = "./modules/vpc"
  providers = { aws = aws.us_east_1 }
}

module "vpc_eu" {
  source = "./modules/vpc"
  providers = { aws = aws.eu_west_1 }
}

3. Expose What Callers Need

# modules/vpc/outputs.tf

# ✅ Expose commonly needed attributes
output "vpc_id" {
  value = aws_vpc.this.id
}

output "private_subnet_ids" {
  value = aws_subnet.private[*].id
}

output "public_subnet_ids" {
  value = aws_subnet.public[*].id
}

output "vpc_cidr_block" {
  value = aws_vpc.this.cidr_block
}

# ✅ Expose the full resource when needed
output "vpc" {
  value       = aws_vpc.this
  description = "Full VPC resource (for advanced use)"
}

4. Use Consistent Naming

# Resource names inside modules: use "this" for the primary resource
resource "aws_vpc" "this" { ... }
resource "aws_s3_bucket" "this" { ... }

# If multiple resources of the same type: use descriptive names
resource "aws_subnet" "public" { ... }
resource "aws_subnet" "private" { ... }

Module Composition

Compose small modules into larger ones:

# modules/platform/main.tf — composes smaller modules
module "vpc" {
  source   = "../vpc"
  vpc_cidr = var.vpc_cidr
}

module "database" {
  source     = "../rds"
  vpc_id     = module.vpc.vpc_id
  subnet_ids = module.vpc.private_subnet_ids
}

module "cluster" {
  source     = "../ecs"
  vpc_id     = module.vpc.vpc_id
  subnet_ids = module.vpc.private_subnet_ids
}
Root config
└── module "platform"
    ├── module "vpc"
    ├── module "database"
    └── module "cluster"

Keep nesting shallow — max 2-3 levels. Deep nesting makes debugging hard.

Versioning

Git Tags

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

Terraform Registry

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 5.0"    # Compatible 5.x releases
}

Version Constraints

version = "5.0.0"     # Exact
version = "~> 5.0"    # >= 5.0.0, < 6.0.0
version = ">= 5.0"    # 5.0 or higher
version = ">= 5.0, < 6.0"  # Explicit range

Use ~> for modules — allows patch/minor updates, blocks breaking changes.

Testing Modules

terraform test

# tests/vpc.tftest.hcl
run "vpc_basic" {
  command = plan

  variables {
    vpc_cidr    = "10.0.0.0/16"
    environment = "test"
  }

  assert {
    condition     = aws_vpc.this.cidr_block == "10.0.0.0/16"
    error_message = "VPC CIDR mismatch"
  }

  assert {
    condition     = length(aws_subnet.private) == 3
    error_message = "Expected 3 private subnets"
  }
}

Example Directory

Every module should have an examples/ directory:

# modules/vpc/examples/basic/main.tf
module "vpc" {
  source = "../../"

  vpc_cidr    = "10.0.0.0/16"
  environment = "example"
  azs         = ["us-east-1a", "us-east-1b"]
}

output "vpc_id" {
  value = module.vpc.vpc_id
}

Common Anti-Patterns

1. God Module

# ❌ One module that does everything
module "infrastructure" {
  source = "./modules/everything"
  # 50 variables for VPC + ECS + RDS + CloudFront + Route53 + ...
}

# ✅ Compose focused modules
module "networking" { source = "./modules/vpc" }
module "database"   { source = "./modules/rds" }
module "compute"    { source = "./modules/ecs" }

2. Thin Wrapper

# ❌ Adds nothing — just use the resource
module "sg" {
  source      = "./modules/security-group"
  name        = "web"
  vpc_id      = module.vpc.vpc_id
  ingress_port = 443
}

3. Leaking Implementation Details

# ❌ Caller must know internal resource structure
output "web_instance_private_dns" {
  value = aws_instance.web_servers["primary"].private_dns
}

# ✅ Clean interface
output "web_endpoint" {
  value = aws_lb.main.dns_name
}

Hands-On Courses

Conclusion

Good modules are focused (one concern), opinionated (sensible defaults), flexible (overridable), and tested (examples + tests). Don’t wrap single resources. Don’t create god modules. Compose small modules into larger ones with max 2-3 levels of nesting. Version with semantic tags and use ~> constraints. The best test for a module: can a new team member use it from the README alone?

🚀

Level Up Your Terraform Skills

Hands-on courses, books, and resources from Luca Berton

Luca Berton
Written by

Luca Berton

DevOps Engineer, AWS Partner, Terraform expert, and author. Creator of Ansible Pilot, Terraform Pilot, and CopyPasteLearn.