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.
- 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.tfis 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 providersvariables.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
descriptionon all variables and outputs - Use
validationblocks 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.mdper module - Keep modules focused: one module = one infrastructure concern
- Test modules with
terraform init && terraform validatein CI - Use
terraform-docsto auto-generate README from variable/output descriptions

