Copies Rot. Modules Can Be Versioned.

A team needed to enforce TLS 1.2 on every storage account. The definitions had been copied between dev, staging and prod, and when someone compared the three network configurations, they differed in fourteen places. Most of the differences were accidents. The TLS change took a day and still missed two accounts.

Modules turn a pattern into something you can version, test and upgrade on purpose. Below is how to structure a module, choose its inputs and defaults, call it with for_each without index surprises, publish and pin versions, and test it before anyone else depends on it.

If the same resources are copy-pasted across environments
  • 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

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
}

Designing the Interface

The hard part of a module is not the resources inside it, it is deciding what callers are allowed to change. Two failure modes show up again and again. The first is the pass-through module, which exposes every argument of every resource as a variable. It saves no one any thinking and turns every provider upgrade into a module change. The second is the sealed module, which hard-codes everything, so the first team with a slightly different need copies it, and you are back to copies that rot.

Aim between the two. Expose the decisions that genuinely vary between callers (name, size, network placement, environment) and make the policy decisions for them (TLS version, encryption, diagnostic settings, tags). Group related settings in one object variable, and give optional attributes defaults so callers only specify what they change:

variable "scaling" {
  description = "Autoscale settings. Omit to use the defaults."
  type = object({
    min_instances = optional(number, 1)
    max_instances = optional(number, 3)
    cpu_target    = optional(number, 70)
  })
  default = {}
}

Keep provider configuration out of modules. A module that declares its own provider block cannot be used withfor_each and fights any caller that needs a different subscription or region. Declare required providers and let callers pass aliased providers in through the providers argument when they need to.

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
}

The Index Surprise, and How to Refactor Safely

count identifies instances by position. Create three app services with count = 3, remove the first item from the list, and Terraform sees instances 0 and 1 with changed arguments and instance 2 gone: it plans to modify two and destroy one, when you only wanted to remove one. for_each identifies instances by key, so removing "dev" only touches dev. Use count for “zero or one” toggles and for_each with stable string keys for everything else.

The same problem appears when you move existing resources into a module or rename them. Terraform tracks resources by address, so a new address looks like a new resource, and the plan says destroy and recreate. A moved block records the rename:

moved {
  from = azurerm_storage_account.logs
  to   = module.logging.azurerm_storage_account.this
}

With that in place the plan shows a move and no changes to the real resource. Keep moved blocks in the code for at least one release, so every environment applies them before they are removed. Inside a shared module, a resource rename without amoved block is a breaking change.

Validating and Testing a Module

A module is an interface, and an interface should reject bad input early. Terraform gives you three levels of checking, from cheapest to most thorough.

variable "environment" {
  type = string
  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "environment must be dev, staging, or prod."
  }
}

resource "azurerm_storage_account" "this" {
  # ...
  lifecycle {
    precondition {
      condition     = var.environment != "prod" || var.replication_type == "GZRS"
      error_message = "Production storage must use GZRS replication."
    }
  }
}

Variable validation catches a wrong value before anything is planned. Preconditions check rules that involve more than one input. For behaviour, the built-in test framework (terraform test, available since Terraform 1.6) runs .tftest.hcl files that plan or apply the module and assert on the result.

# tests/defaults.tftest.hcl
run "prod_requires_gzrs" {
  command = plan
  variables {
    environment      = "prod"
    replication_type = "LRS"
  }
  expect_failures = [azurerm_storage_account.this]
}

Upgrading a Shared Module Without Breaking Callers

Once other teams depend on a module, its version number is a promise. Treat these as breaking changes that need a major version: removing or renaming a variable or output, changing a default in a way that changes existing resources, renaming an internal resource without a moved block, and raising the minimum provider version. Adding an optional variable with a safe default is a minor version; fixing a bug without changing resources is a patch.

On the consuming side, pin exact versions in production and let a bot such as Renovate or Dependabot open pull requests for module upgrades. Each pull request then shows the version bump, the changelog link and, if your pipeline posts it, the plan output. A module upgrade whose plan says no changes for prod is the easy case. One that says replace deserves a careful read of the changelog before anyone approves it.

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