The problem
You wrote a network module for your team. Now five other teams call it. One renames an input and breaks everyone. Another cannot turn off a feature it does not need. A third passes a bad address range and only finds out when Azure rejects the apply twenty minutes in.
A module used by other people is a small product. This lesson is about designing one that is easy to call correctly and hard to call wrongly.
What you need to know already:
- module calls, inputs, outputs, sources and versions (13.34)
- types,
optional(),validation(12.8) count, the splat[*]and the? :conditional (12.20, 12.11)- NSGs (13.11) and CIDR ranges (8.3)
When to write a module
A module is an interface: callers depend on its inputs, outputs and the addresses of its resources. Every interface is a cost, so write one when it pays:
- the same group of resources is needed in several places (every spoke network; every app's storage account + identity + role assignments);
- a group of resources must be built consistently: naming, tags, logging and security settings built in;
- you want to give other teams a golden path - one well-trodden, approved way: "call this and you get a network that already follows the rules".
Do not write one to wrap a single resource with the same arguments renamed (a "thin wrapper"), or just to organise files - file names do that. HashiCorp's rule of thumb: a module should raise the level of abstraction - callers say what they want ("a network with these subnets"), not every detail of how.
The input surface
The variables are what callers see first:
variable "name" {
description = "Name of the virtual network, e.g. vnet-orders-dev."
type = string
validation {
condition = can(regex("^vnet-[a-z0-9-]{3,60}$", var.name))
error_message = "name must look like vnet-<workload>-<env>, lowercase."
}
}
variable "address_space" {
description = "CIDR ranges for the VNet."
type = list(string)
}
variable "subnets" {
description = "Subnets keyed by name."
type = map(object({
cidr = string
service_endpoints = optional(list(string), [])
nsg_rules = optional(map(object({ priority = number, port = string, access = string })), {})
}))
default = {}
}
variable "tags" {
description = "Tags added to every resource."
type = map(string)
default = {}
}
The subnets type in words: a map from subnet name to an object with a required cidr, an optional list of service_endpoints (Azure services the subnet may reach privately; default none), and optional firewall rules (nsg_rules, each with a priority, a port and allow/deny; default none).
What good looks like:
- Few required inputs - only what really differs per caller. Everything else has a safe, opinionated default (TLS 1.2 minimum, no public access, logging on).
- Typed -
object({...})withoptional()rather thanany. The type is documentation that Terraform enforces. - Validated - with error messages written as instructions.
- Named for what the caller wants, not the provider's internals:
enable_private_accessrather thanpublic_network_access_enabled = false. - Stable - every rename breaks every caller.
The output surface
output "vnet_id" { value = azurerm_virtual_network.this.id }
output "vnet_name" { value = azurerm_virtual_network.this.name }
output "subnet_ids" { value = { for k, s in azurerm_subnet.this : k => s.id } }
- Output everything a caller might connect to something else: IDs, names, maps of IDs.
- Key the outputs like the inputs (
subnet_ids["app"]), so callers never depend on list order. - Do not output whole resources. That exposes internals and turns every provider upgrade into a change in your interface.
Composition over nesting
Composition means: the root module calls several small modules side by side and connects them, passing one module's outputs into another's inputs.
root (envs/prod)
├── module "network" -> ./modules/network
├── module "aks" -> ./modules/aks (subnet_id = module.network.subnet_ids["aks"])
└── module "keyvault" -> ./modules/keyvault (principal_id = module.aks.identity_principal_id)
Here aks is a module for a container cluster (AKS, 13.25) placed in one of the network's subnets, and keyvault gives that cluster's identity (principal_id = the ID of an identity in Entra ID) access to a Key Vault. The wiring is all visible in the root.
The alternative, nesting - modules that call modules that call modules - is what to avoid:
- every level adds variables you have to pass through;
- addresses become impossible to type (
module.platform.module.aks.module.pools["system"]...); - a change at the bottom forces a new release of every layer above.
Root -> module -> at most one more level. Each module stays small and testable, and the root reads like an architecture diagram.
Conditional pieces inside a module
A feature flag is a true/false input that switches part of the module on or off:
variable "enable_bastion" {
type = bool
default = false
}
resource "azurerm_public_ip" "bastion" {
count = var.enable_bastion ? 1 : 0
# ...
}
output "bastion_public_ip" {
value = one(azurerm_public_ip.bastion[*].ip_address) # null when disabled
}
(A bastion is a managed "jump box": a single entry point for logging in to machines in a private network.)
count = var.enable_bastion ? 1 : 0creates one or zero instances.azurerm_public_ip.bastion[*].ip_addressis a list of zero or one addresses;one(...)turns that into the single value, ornullwhen the list is empty.
For an optional nested block, the same trick with a dynamic block over a list of zero or one element.
Documentation and examples
Every module ships:
- a README with a short usage example, a table of inputs and outputs (the
terraform-docstool generates it from thedescriptions), and what the module does NOT do; - an examples/ directory with a caller that plans on its own. It is the fastest way for someone to try the module, and what CI tests (13.45).
Versioning and releases
- Tag releases with semantic versions (13.34):
v1.4.0. The public registry requiresvX.Y.Ztags and a repository namedterraform-<PROVIDER>-<NAME>. - Bump the major version for anything that breaks callers: removed or renamed inputs, changed defaults that change infrastructure, resource address changes without
movedblocks (13.46). - When you rename things inside the module, ship
movedblocks in it, so callers who upgrade get moves instead of replacements. Keep them for at least one major version. - Keep a CHANGELOG (a file listing what changed in each version); callers read it before upgrading.
Public modules: Azure Verified Modules
Microsoft maintains Azure Verified Modules (AVM, 13.34) on the public registry: Azure/avm-res-<provider>-<resource>/azurerm for one resource type, Azure/avm-ptn-... for patterns of several.
Reading their source is the best way to see a mature module: many typed inputs, validation, logging, roles and locks built in, examples tested in CI, and an option to turn off the usage reporting they send to Microsoft.
Using them is a trade: less code for you, but a large interface you do not control, and upgrades on their schedule. Pin exact versions and read the release notes.
A checklist for reviewing a module
- Can someone call it correctly from the README alone?
- Does a wrong input fail at plan time with a clear message?
- Are the required inputs few and the defaults safe?
- Are outputs keyed like the inputs, and free of internals?
- No
providerblocks;required_providerswith a minimum version only. - Does it work with
for_eachon the module call? - Is there an example, and does CI plan it (or
terraform testit, 13.53)? - Does every caller pin the version?
What you can now do:
- decide whether something deserves to be a module
- design typed, validated inputs and keyed outputs
- prefer composition in the root over deep nesting