Problem Context

You have a Terraform configuration that deploys an App Service, a PostgreSQL database, a Key Vault, and a Virtual Network. It's 1,200 lines in a single main.tf. When a new team needs to deploy a similar stack, they copy the entire directory and change 20 values โ€” now you have 3 copies drifting apart. When a security requirement changes, you update one copy and forget the others.

Terraform modules are the solution: reusable, composable infrastructure components with documented inputs and outputs. They work like functions โ€” call them with arguments, get back computed values, and reuse them across projects without duplication.

๐Ÿค” Sound familiar?
  • Three teams have nearly identical Terraform configurations with subtle differences
  • A security fix required updating the same resource block in 6 different repos
  • New team members can't figure out what inputs a resource block needs
  • Your main.tf is over 800 lines and getting hard to navigate

Modules turn your infrastructure patterns into a shared library โ€” write once, deploy consistently.

Module Structure

A module is a directory containing .tf files. By convention:

modules/
โ””โ”€โ”€ app-service/
    โ”œโ”€โ”€ main.tf        # Resource definitions
    โ”œโ”€โ”€ variables.tf   # Input declarations
    โ”œโ”€โ”€ outputs.tf     # Output values
    โ””โ”€โ”€ versions.tf    # Required providers

variables.tf โ€” document what the module accepts

# modules/app-service/variables.tf
variable "name" {
  description = "App Service name (will be prefixed with 'app-')"
  type        = string
}

variable "resource_group_name" {
  description = "Name of the resource group to deploy into"
  type        = string
}

variable "location" {
  description = "Azure region"
  type        = string
}

variable "sku_name" {
  description = "App Service Plan SKU"
  type        = string
  default     = "B2"
  validation {
    condition     = contains(["B1", "B2", "B3", "S1", "S2", "P1v3", "P2v3"], var.sku_name)
    error_message = "SKU must be one of: B1, B2, B3, S1, S2, P1v3, P2v3."
  }
}

variable "tags" {
  description = "Tags to apply to all resources"
  type        = map(string)
  default     = {}
}

main.tf โ€” resource definitions

# modules/app-service/main.tf
resource "azurerm_service_plan" "main" {
  name                = "asp-${var.name}"
  resource_group_name = var.resource_group_name
  location            = var.location
  os_type             = "Linux"
  sku_name            = var.sku_name
  tags                = var.tags
}

resource "azurerm_linux_web_app" "main" {
  name                = "app-${var.name}"
  resource_group_name = var.resource_group_name
  location            = var.location
  service_plan_id     = azurerm_service_plan.main.id
  https_only          = true

  site_config {
    minimum_tls_version = "1.2"
    http2_enabled       = true
  }

  tags = var.tags
}

outputs.tf โ€” expose what callers need

# modules/app-service/outputs.tf
output "app_id" {
  description = "Resource ID of the App Service"
  value       = azurerm_linux_web_app.main.id
}

output "hostname" {
  description = "Default hostname"
  value       = azurerm_linux_web_app.main.default_hostname
}

output "identity_principal_id" {
  description = "Principal ID of the app's managed identity"
  value       = azurerm_linux_web_app.main.identity[0].principal_id
}

Calling a Module

# root/main.tf
module "api" {
  source = "./modules/app-service"

  name                = "myapp-api-${var.environment}"
  resource_group_name = azurerm_resource_group.main.name
  location            = azurerm_resource_group.main.location
  sku_name            = var.environment == "prod" ? "P2v3" : "B2"
  tags                = local.common_tags
}

# Use module outputs
resource "azurerm_role_assignment" "api_kv" {
  scope                = azurerm_key_vault.main.id
  role_definition_name = "Key Vault Secrets User"
  principal_id         = module.api.identity_principal_id
}

output "api_hostname" {
  value = module.api.hostname
}

Module Sources

# Local path
source = "./modules/app-service"

# Git repository (pinned to tag)
source = "git::https://github.com/myorg/infra-modules.git//app-service?ref=v2.1.0"

# Git with SSH
source = "git::git@github.com:myorg/infra-modules.git//app-service?ref=v2.1.0"

# Terraform Registry (public or private)
source  = "Azure/compute/azurerm"
version = "~> 5.0"

# Terraform Registry private
source  = "app.terraform.io/myorg/app-service/azurerm"
version = "~> 1.2"

Module Versioning

For shared modules, use semantic versioning. Pin module versions in production; use flexible ranges in development:

module "network" {
  source  = "git::https://github.com/myorg/tf-modules.git//network?ref=v3.2.1"
  # Pin to exact tag in production โ€” never use 'main' branch

  vpc_cidr = "10.0.0.0/16"
  ...
}

For-Each with Modules

# Deploy the same module for multiple environments
variable "environments" {
  default = {
    dev  = { sku = "B2",  location = "westus2" }
    prod = { sku = "P2v3", location = "eastus"  }
  }
}

module "app" {
  for_each = var.environments
  source   = "./modules/app-service"

  name                = "myapp-${each.key}"
  resource_group_name = azurerm_resource_group.envs[each.key].name
  location            = each.value.location
  sku_name            = each.value.sku
}

Production Checklist

  • Every module must have description on all variables and outputs
  • Use validation blocks to catch invalid inputs before deployment
  • Pin module source to a git tag or registry version โ€” never use a branch reference in production
  • Document breaking changes in a CHANGELOG.md per module
  • Keep modules focused: one module = one infrastructure concern
  • Test modules with terraform init && terraform validate in CI
  • Use terraform-docs to auto-generate README from variable/output descriptions