CloudOpsGuide
terraform

Terraform Modules: Create and Reuse

Intermediate
15 minutes
October 2026
CloudOpsGuide Team

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

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