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 "resource_group_name" {
description = "Name of the resource group the storage account belongs to"
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.

The rule a reusable module must follow: never declare your own provider

Section titled “The rule a reusable module must follow: never declare your own provider”

A module intended for reuse should almost never contain a provider block with real configuration in it. Per the official guidance (developer.hashicorp.com/terraform/language/modules/develop/providers): “Modules intended for use by other modules should not contain provider blocks. Modules with their own provider configurations are incompatible with arguments like for_each, count, and depends_on.” That is not a style preference — it is enforced, with a real error. Proof: a child module with a genuine, non-empty provider "azurerm" { features {}; subscription_id = "..." } block, called with count:

# @expect-error Module is incompatible with count, for_each, and depends_on
terraform {
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 5.0"
}
}
}
provider "azurerm" {
features {}
subscription_id = "00000000-0000-0000-0000-000000000000"
}
resource "azurerm_resource_group" "this" {
name = "acme-example-rg"
location = "eastus"
}
# @expect-error Module is incompatible with count, for_each, and depends_on
module "rg" {
source = "./modules/rg"
count = 2
}

Real terraform init output on Terraform 1.16.4:

$ terraform init
Error: Module is incompatible with count, for_each, and depends_on
on main.tf line 12, in module "rg":
12: count = 2
The module at module.rg is a legacy module which contains its own local
provider configurations, and so calls to it may not use the count,
for_each, or depends_on arguments.
If you also control the module "./modules/rg", consider updating this
module to instead expect provider configurations to be passed by its
caller.

This is the exact same error text — and the exact same current-version-vs-older-docs-wording mismatch — proven identically in the aws and gcp siblings of this course, because it is a core Terraform language rule, not something azurerm-specific. The fix is configuration_aliases: the module declares it needs a provider passed in under a local alias, and never configures one itself.

# Fixed: modules/rg/main.tf never owns a provider block
terraform {
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 5.0"
configuration_aliases = [azurerm.this]
}
}
}
resource "azurerm_resource_group" "this" {
provider = azurerm.this
name = "acme-example-rg"
location = "eastus"
}
# Fixed: root main.tf passes the provider explicitly
module "rg" {
source = "./modules/rg"
count = 2
providers = {
azurerm.this = azurerm
}
}

Real terraform validate output after the fix (azurerm’s plan needs a real subscription, unlike aws’s mock-credential path — this course validates azurerm rather than planning it, exactly as established in earlier lessons):

$ terraform validate
Success! The configuration is valid.

providers = {} in the calling module block is how the caller hands the module a specific (possibly aliased) provider configuration explicitly, instead of the module silently inheriting the default provider the way it would if it declared no provider block and no configuration_aliases at all. A module written this way works identically whether it is called once, with count, or with for_each — because it never owns a provider configuration to begin with.

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

A module is reusable in direct proportion to how little it assumes about its caller. File layout, sensible defaults, and resisting over-parameterization all serve that goal at the configuration level; the provider rule proven above serves it at the provider level — a module that owns its own provider block has quietly assumed “I will only ever be called once, with the default provider,” and that assumption becomes a real, named error the instant a caller tries count, for_each, or depends_on. configuration_aliases plus a caller-side providers = {} map is how a module states its provider dependency explicitly instead of assuming it.

ChoiceBenefitCost
One clear responsibility per moduleReusable across many callers without modificationMore modules to navigate; a caller may need to wire several together (covered in module-composition-and-sources.mdx)
Sensible defaults on optional inputsSimple callers need only the required inputsA default can hide a real decision a caller should have made consciously
configuration_aliases instead of an owned provider blockWorks with count/for_each/depends_on — proven above, the alternative hard-fails with a real errorThe caller must remember to pass providers = {}; forgetting it is its own error to debug
  • “A module needs its own provider block to know which Azure subscription to use.” Proven above: the opposite is true for a reusable module — an owned provider block is exactly what makes count/for_each/depends_on fail with a real, named error. The subscription should come from the caller.
  • “The count/for_each restriction on modules with providers is just a style lint, not a hard error.” Proven above with a real terraform init failure on Terraform 1.16.4: “Module is incompatible with count, for_each, and depends_on.”
  • “This is an azurerm-specific quirk.” Proven above by exact-text comparison across this course’s three sibling repos: the aws, azurerm, and google providers all produce the identical Terraform-core error text, because the restriction lives in Terraform itself, not in any one provider.
  • “You can always trust an error message’s exact wording from memory or from a docs page you read a while ago.” Proven above: the officially documented example text (“Module does not support count … contains a nested provider configuration”) does not match the real Terraform 1.16.4 output (“Module is incompatible with count, for_each, and depends_on”) — always re-verify against the pinned version.

When reviewing a generated module intended to be reusable, check for:

  • A provider block with real, non-empty arguments inside a module directory — proven above, this silently breaks the moment a caller tries count, for_each, or depends_on on that module, often far from where the provider block was originally written.
  • A module that hardcodes a subscription ID, region, or credential-adjacent setting instead of accepting it (or an aliased provider) from its caller.
  • Every field a resource supports exposed as a module variable “for completeness,” rather than added when a real caller needs that specific variation — proven above, each one is a permanent piece of public API to maintain.
  • A required variable with no description, making the module’s public API harder for a caller to use correctly without reading the resource block itself.

🐛 Spot the bug

modules/queue/main.tf
terraform {
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 5.0"
}
}
}
provider "azurerm" {
features {}
subscription_id = "11111111-1111-1111-1111-111111111111"
}
resource "azurerm_storage_queue" "this" {
name = var.queue_name
storage_account_name = var.storage_account_name
}
Show the answer

Proven above with a real terraform init failure: this module owns a genuine, configured provider "azurerm" block, which per the official guidance (“Modules intended for use by other modules should not contain provider blocks”) makes it incompatible with count, for_each, and depends_on the moment any caller tries to use one of those on the module block — the exact, real error is “Module is incompatible with count, for_each, and depends_on … contains its own local provider configurations.” Worse, it silently hardcodes a specific subscription ID, so every caller gets that subscription regardless of their own provider configuration, with no way to override it. The fix is configuration_aliases = [azurerm.this] in required_providers, referencing provider = azurerm.this on the resource, and no provider block in the module at all — the caller then supplies the actual provider via providers = { azurerm.this = azurerm }, proven above to validate cleanly even with count = 2.

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 real module content, proven above, causes this exact error?
$ terraform init
Error: Module is incompatible with count, for_each, and depends_on

  on main.tf line 12, in module "rg":
  12:   count  = 2

The module at module.rg is a legacy module which contains its own local
provider configurations, and so calls to it may not use the count,
for_each, or depends_on arguments.
Is the "Module is incompatible with count, for_each, and depends_on" error specific to the azurerm provider?
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