Module Composition and Sources
The idea in one sentence
Section titled “The idea in one sentence”The source argument on a module block says where that module’s code lives — local path, public Registry, or Git URL — and real infrastructure is built by wiring one module’s output into the next module’s variable, not by writing one giant flat configuration.
The source argument: where module code comes from
Section titled “The source argument: where module code comes from”Every module block needs a source argument, and Terraform supports three common forms for it. The simplest is a local relative path, pointing at a directory of .tf files that lives inside the same repository as the caller:
module "vpc" { source = "./modules/vpc"
project_id = var.project_id network_name = "app-network"}A local path is the right choice while a module is still specific to one repository, or while you are actively developing it alongside the code that calls it. Once a module is genuinely reusable across many configurations, though, you usually want to publish it somewhere every caller can reach independently of any one repository — and that is where the public Terraform Registry and Git sources come in.
The public Terraform Registry hosts community and vendor modules under a namespace/name/provider address, resolved automatically by terraform init without any URL:
module "vpc" { source = "terraform-google-modules/network/google" version = "~> 18.0"
project_id = var.project_id network_name = "app-network"}A Git URL is the third form, useful for private or in-house modules that are not published to any registry. Appending ?ref= pins the source to a specific tag, branch, or commit, the same way version pins a Registry module:
module "vpc" { source = "git::https://github.com/acme-corp/terraform-modules.git//vpc?ref=v2.3.0"
project_id = var.project_id network_name = "app-network"}Note the double slash before vpc in the Git URL — everything before it is the repository to clone, and everything after it is a subdirectory inside that repository where the module actually lives. This matters because a single Git repository commonly hosts many modules side by side, not just one.
Composing modules: wiring outputs into inputs
Section titled “Composing modules: wiring outputs into inputs”Non-trivial infrastructure is essentially never one giant flat configuration. It is a handful of small, focused modules wired together, where one module’s output becomes the very next module’s input variable. Consider a vpc module that creates a network and its subnets, and a compute-instance module that needs to know which network and subnetwork to attach an instance to:
# Illustrative: modules/vpc/outputs.tf (the vpc module's own resources are omitted here)output "network_id" { description = "Self link of the VPC network created by this module" value = google_compute_network.this.id}
output "subnet_ids" { description = "Map of region to subnetwork ID" value = { for s in google_compute_subnetwork.this : s.region => s.id }}variable "network_id" { description = "ID of the VPC network to attach the instance to" type = string}
variable "subnet_id" { description = "ID of the subnetwork to attach the instance to" type = string}The root configuration instantiates both modules and threads the first module’s outputs straight into the second module’s inputs, with no manual copying of IDs in between:
# root main.tfmodule "vpc" { source = "./modules/vpc" project_id = var.project_id network_name = "app-network"}
module "compute_instance" { source = "./modules/compute-instance" project_id = var.project_id network_id = module.vpc.network_id subnet_id = module.vpc.subnet_ids["us-central1"]}Terraform resolves this dependency automatically: because module.compute_instance references module.vpc.network_id, Terraform knows to create the network before the instance, without you writing an explicit depends_on anywhere.
Reaching for the public Terraform Registry
Section titled “Reaching for the public Terraform Registry”Writing a vpc module completely from scratch is a reasonable learning exercise, but for production use it is often not the best use of your time. The terraform-google-modules organization on the public Registry maintains modules like terraform-google-modules/network/google that already handle the fiddly details of GCP networking — custom subnet ranges, secondary ranges for GKE, Shared VPC, Cloud NAT — vetted by a large community of users hitting the same edge cases you would eventually hit yourself.
That said, a Registry module is still someone else’s code running in your project with your credentials. Before depending on one in production, read through what it actually creates — its main.tf, its default values, the IAM roles or APIs it enables on your behalf. A well-maintained Registry module is a real productivity lever, not a replacement for understanding what gets deployed into your GCP project.
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.vpc and module.compute_instance 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.
# network/main.tf — a separate configuration, applied on its ownresource "google_compute_network" "this" { name = "app-network" auto_create_subnetworks = false}
output "network_id" { value = google_compute_network.this.id}# app/main.tf — a different configuration, reading network's statedata "terraform_remote_state" "network" { backend = "gcs" config = { bucket = "my-company-terraform-state" prefix = "network/state" }}
resource "google_compute_instance" "web" { name = "web-vm" machine_type = "e2-medium" zone = "us-central1-a"
boot_disk { initialize_params { image = "debian-cloud/debian-12" } }
network_interface { network = data.terraform_remote_state.network.outputs.network_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:
network_id = "projects/my-project/global/networks/app-network"
$ cd ../app && terraform plan + content = <<-EOT network_id=projects/my-project/global/networks/app-network EOTThe plan output shows the real value read out of network’s state file — data.terraform_remote_state.network.outputs.network_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 = google_compute_instance.web to = module.compute_instance.google_compute_instance.this}
module "compute_instance" { source = "./modules/compute-instance" # ...}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 Compute Engine resource — as long as the moved block travels with the refactor in the same commit.
flowchart LR
subgraph root["Root module"]
rvpc["module.vpc"]
rci["module.compute_instance"]
end
subgraph vpcmod["Child module: modules/vpc"]
vout1["output: network_id"]
vout2["output: subnet_ids"]
end
subgraph cimod["Child module: modules/compute-instance"]
cin1["variable: network_id"]
cin2["variable: subnet_id"]
end
rvpc --> vout1
rvpc --> vout2
vout1 -->|module.vpc.network_id| rci
vout2 -->|module.vpc.subnet_ids| rci
rci --> cin1
rci --> cin2 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 vpc 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. - “A Registry module’s
versionconstraint guarantees the module’s arguments never change between minor versions.” Not proven or claimed here —~>still allows patch/minor updates within the constraint (covered in depth inmodule-versioning-and-registries.mdx, including a real required-argument change interraform-google-modules/network/googlev18), so pinning a version narrows drift but does not eliminate it.
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 pinned with no
versionconstraint at all, whichmodule-versioning-and-registries.mdxcovers as a real, provable risk.
🐛 Spot the bug
# app/main.tf — refactored so the instance now launches from a module,# migrated from a bare resource block in the same commitmodule "compute_instance" {source = "./modules/compute-instance"network_id = data.terraform_remote_state.network.outputs.network_id}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 google_compute_instance.web at the root disappears, and module.compute_instance.google_compute_instance.this appears as brand new, which plans as delete the running Compute Engine instance, create a new one. For something reading traffic through that instance, that is real downtime and a new instance 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.