Skip to content

Writing a Reusable Module

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.”

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 module
module "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.

modules/storage-account/variables.tf
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
}
modules/storage-account/main.tf
resource "azurerm_storage_account" "this" {
name = var.name
resource_group_name = var.resource_group_name
location = "eastus"
account_tier = "Standard"
account_replication_type = "LRS"
}
modules/storage-account/outputs.tf
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.tf
module "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 default in variables.tf so 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.
modules/storage-account/variables.tf
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
A module directory called from a root configuration via a module block
What technically makes a directory of Terraform files a module rather than a root module
Why does a reusable modules file layout typically mirror variables.tf, main.tf, and outputs.tf
What is the practical cost of over-parameterizing a module by exposing every possible resource field as a variable on day one
In the storage-account module example, why does name have no default while account_replication_type does