Skip to content

Module Composition and Sources

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 = "~> 9.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:

modules/vpc/outputs.tf
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 }
}
modules/compute-instance/variables.tf
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.tf
module "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.

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
Root configuration wiring vpc module outputs into compute-instance module inputs
What are the three common forms a module source argument can take
In the vpc and compute-instance composition example, how does the compute-instance module learn which network to attach to
What does the ref query parameter do on a Git module source
When does reaching for a public Terraform Registry module like terraform-google-modules/network/google make the most sense