Writing a Reusable Module
The idea in one sentence
Section titled “The idea in one sentence”A module is just any directory of .tf files — the one you run terraform apply in is the root module, and any directory you call into with a module block is a child module, so there is nothing structurally special about “being a module.”
There is no module keyword — only usage
Section titled “There is no module keyword — only usage”This trips people up the first time they hear it: Terraform has no special file, no module.tf, no magic marker that turns a directory into a module. Every directory of .tf files you have ever written is already a module. The directory where you run terraform init and terraform apply directly is called the root module. The moment you reference some other directory of .tf files from inside a module block, that other directory becomes a child module — but its own .tf files look exactly the same as any root configuration’s:
# root main.tf — calling a child modulemodule "storage" { source = "./modules/storage-account" name = "acmeapplogs" resource_group_name = azurerm_resource_group.main.name}That is the entire distinction: a child module is a directory of Terraform configuration that some other configuration references by address. Nothing inside the module’s own .tf files has to change to make this true — the same directory could be applied directly as a root module if you cd into it and ran terraform apply there yourself.
Why a module’s file layout mirrors a root config
Section titled “Why a module’s file layout mirrors a root config”Because a module is a Terraform configuration, it follows the exact same three-file convention you already use for root configs, just with a different audience in mind:
variables.tf— the module’s inputs. This is its public API surface: what a caller must or may provide.main.tf— the resources the module actually manages.outputs.tf— the values the module exposes back to whoever called it.
This is worth internalizing: a module is not a special templating construct layered on top of Terraform. It is a normal Terraform configuration that happens to be parameterized by variables.tf on the way in and by outputs.tf on the way out.
variable "name" { description = "Name of the storage account to create" type = string}
variable "resource_group_name" { description = "Name of the resource group the storage account belongs to" type = string}resource "azurerm_storage_account" "this" { name = var.name resource_group_name = var.resource_group_name location = "eastus" account_tier = "Standard" account_replication_type = "LRS"}output "primary_blob_endpoint" { description = "Primary blob endpoint of the created storage account" value = azurerm_storage_account.this.primary_blob_endpoint}A caller wires this up with a module block, treating name and resource_group_name as inputs and module.<name>.primary_blob_endpoint as the output it can reference elsewhere:
# root main.tfmodule "app_logs" { source = "./modules/storage-account" name = "acmeapplogs" resource_group_name = azurerm_resource_group.main.name}
output "app_logs_endpoint" { value = module.app_logs.primary_blob_endpoint}Designing a module with one clear responsibility
Section titled “Designing a module with one clear responsibility”The single biggest design mistake with modules is scope creep: a module that starts as “a resource group and a virtual network” slowly grows a storage account here, a role assignment there, a virtual machine somewhere else, until nobody can reuse it for anything but the one thing it was originally built for. Aim for a module that does one well-scoped job — “a resource group plus a virtual network with subnets,” not “all of our Azure networking, IAM, and compute.”
Two practical habits keep a module reusable instead of accidentally specific to one caller:
- Sensible defaults for optional inputs. If most callers want the same value for an input, give that input a
defaultinvariables.tfso a simple caller does not have to specify it at all. - Resist over-parameterizing on day one. It is tempting to expose every field a resource supports as a variable “just in case.” Every variable you add is a permanent piece of the module’s public API that you now have to document and maintain. Add an input when a real caller actually needs that variation — not preemptively for a hypothetical one.
variable "name" { description = "Name of the storage account to create" type = string}
variable "account_replication_type" { description = "Replication strategy for the storage account" type = string default = "LRS"}Here account_replication_type has a sensible default — most callers never touch it — while name has none, because every caller genuinely needs a different storage account name and there is no reasonable default to guess.
flowchart LR
subgraph root["Root module"]
rmain["main.tf calls module.storage"]
end
subgraph child["Child module: modules/storage-account"]
vars["variables.tf (inputs)"] --> main["main.tf (resources)"]
main --> outs["outputs.tf (values exposed)"]
end
rmain -->|source = ./modules/storage-account| vars
outs -->|module.storage.primary_blob_endpoint| rmain