Terraform Modules: Create and Reuse
Terraform Modules: Create and Reuse
Build reusable Terraform modules for infrastructure with best practices, versioning, and real examples you can use today.
Table of Contents
- What Is a Module
- Module Structure
- Building Your First Module
- Calling Modules
- Versioning with Git
- Module Best Practices
- Common Mistakes
What Is a Module
A module is a directory of .tf files used together. Every Terraform config has a root module; anything you call with module blocks is a child module.
Modules exist to:
- Reuse the same pattern everywhere (e.g., "a standard web app")
- Encapsulate complexity — callers set 5 variables, not 50 resources
- Enforce standards — tagging, naming, security baked in
Module Structure
modules/
└── web-app/
├── main.tf # resources
├── variables.tf # inputs
├── outputs.tf # outputs
├── versions.tf # provider constraints
└── README.md # usage docs
No magic: modules are plain .tf files in a folder.
Building Your First Module
modules/resource-group/main.tf
resource "azurerm_resource_group" "this" {
name = "${var.name}-${var.environment}"
location = var.location
tags = merge(var.tags, {
Environment = var.environment
ManagedBy = "Terraform"
})
}
modules/resource-group/variables.tf
variable "name" {
description = "Base name for the resource group"
type = string
validation {
condition = can(regex("^[a-z0-9-]+$", var.name))
error_message = "Name must be lowercase alphanumeric with hyphens."
}
}
variable "environment" {
description = "Environment: dev, staging, or prod"
type = string
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "Environment must be dev, staging, or prod."
}
}
variable "location" {
description = "Azure region"
type = string
default = "uksouth"
}
variable "tags" {
description = "Additional resource tags"
type = map(string)
default = {}
}
modules/resource-group/outputs.tf
output "name" {
description = "Resource group name"
value = azurerm_resource_group.this.name
}
output "id" {
description = "Resource group ID"
value = azurerm_resource_group.this.id
}
output "location" {
value = azurerm_resource_group.this.location
}
modules/resource-group/versions.tf
terraform {
required_version = ">= 1.6"
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 4.0"
}
}
}
Calling Modules
Local Path
module "rg_dev" {
source = "./modules/resource-group"
name = "payments"
environment = "dev"
location = "uksouth"
tags = {
Team = "Payments"
}
}
module "rg_prod" {
source = "./modules/resource-group"
name = "payments"
environment = "prod"
}
Git Source (Shared Modules)
module "rg" {
source = "git::https://github.com/myorg/tf-modules.git//resource-group?ref=v1.2.0"
name = "payments"
environment = "prod"
}
The ?ref= pins a git tag — always pin versions for shared modules.
Terraform Registry
module "vnet" {
source = "Azure/vnet/azurerm"
version = "4.1.0"
vnet_name = "prod-vnet"
resource_group_name = module.rg_prod.name
address_space = ["10.0.0.0/16"]
}
Versioning with Git
# Tag releases semantically
git tag v1.0.0
git push --tags
# Callers pin:
# source = "git::https://...//module?ref=v1.0.0"
Upgrade path: bump the tag, update ref= in caller, terraform init -upgrade, terraform plan.
Module Best Practices
1. Keep the Interface Small
# Bad — exposes everything, brittle
variable "node_count" {}
variable "vm_size" {}
variable "os_disk_type" {}
# ... 40 more variables
# Good — opinionated defaults, few knobs
variable "size" {
type = string
default = "medium" # maps internally to real settings
}
2. Validate Inputs
variable "cidr" {
type = string
validation {
condition = can(cidrhost(var.cidr, 0))
error_message = "Must be a valid CIDR block."
}
}
3. Compose Modules, Don't Nest Deeply
root
├── module.network
├── module.aks (takes network outputs)
└── module.app (takes aks outputs)
Deeply nested modules are painful to debug. Prefer flat composition at the root.
4. Document with README + Examples
# Usage
module "rg" {
source = "./modules/resource-group"
name = "app"
environment = "prod"
}
5. Output What Callers Need
IDs and names other modules will consume — not every attribute.
Common Mistakes
Count/for_each at module level changing index
# Dangerous — removing an item destroys others by index shift
module "vms" {
count = length(var.vm_names)
name = var.vm_names[count.index]
}
# Better — keyed by name
module "vms" {
for_each = toset(var.vm_names)
name = each.key
}
Provider blocks inside modules
# Inside a module — DON'T do this
provider "azurerm" {
alias = "east"
}
Pass providers from the root instead:
module "east" {
source = "./modules/app"
providers = {
azurerm = azurerm.east
}
}
Relative path hell
source = "../../../../../modules/vnet" breaks when files move. Prefer a registry or git source for anything shared beyond one repo.
Testing Modules
# Validate standalone
cd modules/resource-group
terraform init
terraform validate
# Test with a fixture
mkdir test && cat > test/main.tf <<EOF
module "test" {
source = "../"
name = "test"
environment = "dev"
}
EOF
cd test && terraform init && terraform plan
Related Articles
Last Updated: October 2026
Author: CloudOpsGuide Team
Difficulty: Intermediate
Estimated Reading Time: 15 minutes