Module Composition and Sources
The idea in one sentence
Section titled “The idea in one sentence”The source argument on a module block tells Terraform where to fetch that module’s code from, and real, non-trivial infrastructure gets built by composing several small modules together — one module’s output feeding directly into another module’s input variable.
Where a module’s code comes from: the source argument
Section titled “Where a module’s code comes from: the source argument”Every module block needs a source, and there are three kinds you will run into constantly. A local relative path points at a directory inside your own repository. The public Terraform Registry resolves a short <namespace>/<name>/<provider> address to a published, versioned module. A Git URL points directly at a repository, optionally pinned to a specific tag, branch, or commit with ?ref=.
# local relative pathmodule "storage" { source = "./modules/storage-account"}
# public Terraform Registrymodule "vnet" { source = "Azure/vnet/azurerm" version = "~> 5.0"}
# Git URL pinned to a specific tagmodule "vnet_from_git" { source = "git::https://github.com/acme/terraform-modules.git//vnet?ref=v1.4.0"}All three forms are just different answers to the same question: where does Terraform go get the .tf files for this child module. Nothing about how you write variables.tf, main.tf, or outputs.tf inside the module changes based on which kind of source a caller happens to use.
Composing modules: one output becomes another’s input
Section titled “Composing modules: one output becomes another’s input”A single flat configuration with hundreds of resources is hard to read and impossible to reuse. Real infrastructure is built from small, focused modules wired together through their outputs and inputs. A vnet module exposes the identifiers other modules need:
# Illustrative: modules/vnet/outputs.tf (the vnet module's own resources are omitted here)output "vnet_id" { description = "ID of the created virtual network" value = azurerm_virtual_network.this.id}
output "subnet_ids" { description = "IDs of the subnets" value = azurerm_subnet.this[*].id}A virtual-machine module declares the inputs it needs to launch into that network:
variable "vnet_id" { description = "ID of the virtual network to launch the VM into" type = string}
variable "subnet_id" { description = "ID of the subnet to launch the VM into" type = string}The root configuration is what actually wires them together, passing one module’s output straight into another module’s input:
# root main.tfmodule "vnet" { source = "./modules/vnet" address_space = "10.0.0.0/16"}
module "web_server" { source = "./modules/virtual-machine"
vnet_id = module.vnet.vnet_id subnet_id = module.vnet.subnet_ids[0]}Neither module needs to know the other exists. The vnet module has no idea a virtual-machine module will ever consume its outputs, and the virtual-machine module has no idea its vnet_id came from a module rather than a hardcoded string. The root configuration is the only place that knows both modules and connects them.
The public Terraform Registry as a productivity lever
Section titled “The public Terraform Registry as a productivity lever”Common patterns like “a virtual network with public and private subnets” have been solved many times already. Older tutorials often point at the Azure organization’s community modules — Azure/vnet/azurerm, Azure/naming/azurerm — as the starting point, but checking the registry page for each one directly shows both are archived, with a banner pointing adopters at their replacements. The public Terraform Registry’s current, actively maintained answer for common Azure patterns is the Azure Verified Modules (AVM) program instead — a joint Microsoft/HashiCorp effort, not a community project — and module-versioning-and-registries.mdx covers a real, validated AVM module call plus its version constraint in depth.
That said, “actively maintained” is not the same as “safe to trust blindly.” Before you build production infrastructure on top of any Registry module — AVM included — read what it actually creates: its resources, its default values, and any role assignments it provisions on your behalf. A Registry module is still someone else’s code running with your Azure credentials.
Composing across separate configurations with terraform_remote_state
Section titled “Composing across separate configurations with terraform_remote_state”Everything above wires modules together inside one Terraform configuration, where the root has both module.vnet and module.web_server in the same terraform apply. Real infrastructure is often split into genuinely separate configurations with their own state — a platform team’s networking config applied on its own schedule, an application team’s config applied separately. terraform_remote_state is the native, no-Terragrunt way to bridge them: a data source that reads another configuration’s outputs straight out of its state, by whatever backend that state lives in.
# Illustrative: network/main.tf — a separate configuration, applied on its ownresource "azurerm_resource_group" "main" { name = "acme-network-rg" location = "eastus"}
resource "azurerm_virtual_network" "this" { name = "acme-vnet" resource_group_name = azurerm_resource_group.main.name location = azurerm_resource_group.main.location address_space = ["10.0.0.0/16"]}
output "vnet_id" { value = azurerm_virtual_network.this.id}# Illustrative: app/main.tf — a different configuration, reading network's statedata "terraform_remote_state" "network" { backend = "azurerm" config = { resource_group_name = "tfstate-rg" storage_account_name = "acmetfstate001" container_name = "tfstate" key = "network.terraform.tfstate" }}
resource "azurerm_network_interface" "web" { name = "web-nic" resource_group_name = "acme-app-rg" location = "eastus"
ip_configuration { name = "internal" subnet_id = data.terraform_remote_state.network.outputs.vnet_id private_ip_address_allocation = "Dynamic" }}
resource "azurerm_linux_virtual_machine" "web" { name = "web-vm" # ... network_interface_ids = [ azurerm_network_interface.web.id, ]}Real proof with no cloud credentials at all — two local-backed configurations standing in for network and app, with terraform_remote_state reading a real state file across the boundary:
$ cd network && terraform apply -auto-approveOutputs:
vnet_id = "vnet-0123456789abcdef0"
$ cd ../app && terraform plan + content = <<-EOT vnet_id=vnet-0123456789abcdef0 EOTThe plan output shows the real value read out of network’s state file — data.terraform_remote_state.network.outputs.vnet_id resolved to the exact string network’s own apply produced, with no direct dependency between the two configurations’ source code at all. Neither configuration imports the other; app only knows the shape of network’s outputs and where its state lives.
This is the mechanism Terragrunt’s dependency block (covered in terragrunt-fundamentals/dependencies-between-units.mdx) builds on top of and mostly replaces for multi-environment setups — terraform_remote_state is the one that works with plain Terraform, no Terragrunt required, which is exactly why it still shows up constantly in codebases that predate Terragrunt adoption or that intentionally avoid it.
moved across module boundaries: refactoring composition without downtime
Section titled “moved across module boundaries: refactoring composition without downtime”Composition is not static — a resource that starts at the root often gets pulled into a module later, once a second caller needs the same thing. Left alone, that refactor reads to Terraform as “the old address is gone, a new one appeared,” which plans as destroy-then-create. A moved block, spanning the module boundary, tells Terraform these are the same real object:
# root main.tfmoved { from = azurerm_linux_virtual_machine.web to = module.web_server.azurerm_linux_virtual_machine.this}
module "web_server" { source = "./modules/virtual-machine" # ...}Real proof, using the same no-credentials local_file pattern this course uses throughout for state mechanics — a resource that existed at the root, pulled into a module, with moved spanning the boundary:
$ terraform planlocal_file.config: Refreshing state... [id=5d54098470458257525a27f6f0b6de89e278569d]
Terraform will perform the following actions:
# local_file.config has moved to module.config.local_file.this resource "local_file" "this" { id = "5d54098470458257525a27f6f0b6de89e278569d" # (10 unchanged attributes hidden) }
Plan: 0 to add, 0 to change, 0 to destroy.Plan: 0 to add, 0 to change, 0 to destroy is the proof that matters: pulling a resource into a module, one of the single most common refactors in a growing configuration, does not have to mean recreating a real Azure resource — as long as the moved block travels with the refactor in the same commit.
flowchart LR
subgraph root["Root configuration"]
vnetmod["module vnet"]
vmmod["module web_server"]
end
subgraph vnet["Child module: vnet"]
vnetout1["output vnet_id"]
vnetout2["output subnet_ids"]
end
subgraph vm["Child module: virtual-machine"]
vmin1["var.vnet_id"]
vmin2["var.subnet_id"]
end
vnetmod --> vnetout1
vnetmod --> vnetout2
vnetout1 -->|module.vnet.vnet_id| vmin1
vnetout2 -->|module.vnet.subnet_ids[0]| vmin2
vmin1 --> vmmod
vmin2 --> vmmod Mental Model
Section titled “Mental Model”Composition has two very different shapes in this lesson, and telling them apart matters. module blocks composing inside one configuration is compile-time-ish wiring — the root configuration sees both modules’ full graphs and plans them together in one apply. terraform_remote_state composing across configurations is runtime wiring against a snapshot — app reads whatever network last wrote to its state, which could be stale the moment network changes again, with no automatic re-plan on app’s side. Both are real, valid ways to connect infrastructure; the difference is blast radius and staleness, not correctness. moved across a module boundary is the third piece: it is what lets you migrate between these shapes (pulling a root resource into a module, or vice versa) without Terraform reading the refactor as real infrastructure churn.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
module composition (one apply) | Root config sees the full picture; a single plan shows every downstream effect | Every module in the graph gets planned together — a small vnet change can widen a plan that touches everything wired to it |
terraform_remote_state across configs | Genuinely separate apply lifecycles — a platform team’s network changes do not force an app team’s plan to run | Reads a snapshot, not live state — proven above, app only sees what network’s last apply wrote; it can drift out of sync until re-planned |
moved across a module boundary | Refactors a resource into (or out of) a module without destroy/recreate — proven above: 0 to add, 0 to change, 0 to destroy | Must be written and committed in the exact same change as the refactor, or the window between them plans as a real destroy/recreate |
Common Misconceptions
Section titled “Common Misconceptions”- “
terraform_remote_stategives you a live connection to the other configuration’s current state.” Proven above: it reads whatever was last written to that state object — ifnetworkchanges and re-applies afterapphas already planned,app’s nextplan(not the one already run) is what picks up the new value. - “You need Terragrunt to compose separate Terraform configurations together.” Proven above:
terraform_remote_stateis a plain Terraformdatasource, no Terragrunt required — it predates Terragrunt’s owndependencyblock and still appears constantly in codebases that do not use Terragrunt at all. - “Moving a resource into a module is inherently disruptive — you have to destroy and recreate it.” Proven above with a real
local_fileexample: with amovedblock spanning the module boundary, the exact same refactor plans as0 to add, 0 to change, 0 to destroy. - “
Azure/vnet/azurermandAzure/naming/azurermare still the standard starting point for Azure networking modules.” Checked directly against GitHub: both repositories carry a real “This repository was archived by the owner” banner (Nov 2024 and Sep 2026 respectively) and point adopters at Azure Verified Modules — a course or tutorial still teaching these as the current answer is citing archived guidance.
Reading AI-generated code
Section titled “Reading AI-generated code”When reviewing generated module composition or cross-configuration wiring, check for:
- A
terraform_remote_statedata source assumed to always reflect the current state of the other configuration — proven above, it only reflects that configuration’s lastapply, not a live read. - A resource moved into a module in generated code with no accompanying
movedblock — proven above, this reads as destroy-then-create without one, exactly the same risk covered for same-configuration refactors instate-management/drift-detection-and-import.mdx. - Two Terraform configurations that both directly modify the same real resource through different paths (one via a
module, one viaterraform_remote_stateplus a resource block) instead of one owning it and the other only reading its outputs. - A Registry module source pointing at
Azure/vnet/azurermorAzure/naming/azurerm— checked above, both are archived; the current answer is an Azure Verified Module, covered with a real validated example inmodule-versioning-and-registries.mdx.
🐛 Spot the bug
# app/main.tf — refactored so the VM now launches from a module,# migrated from a bare resource block in the same commitmodule "web_server" {source = "./modules/virtual-machine"subnet_id = data.terraform_remote_state.network.outputs.subnet_ids[0]}Show the answer
Proven above with a real plan (local_file.config -> module.config.local_file.this): pulling a resource into a module with no accompanying moved { from = ... to = ... } block does not read as a refactor to Terraform at all — the resource that used to be azurerm_linux_virtual_machine.web at the root disappears, and module.web_server.azurerm_linux_virtual_machine.this appears as brand new, which plans as deallocate and delete the running VM, provision a new one. For something reading traffic through that VM, that is real downtime and a new VM resource ID, for a change that was, in the author’s intent, purely organizational. The fix is a moved block in the same commit as the refactor — proven above to produce Plan: 0 to add, 0 to change, 0 to destroy with a has moved to note spanning the module boundary instead.