Writing a Reusable Module
The idea in one sentence
Section titled “The idea in one sentence”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.”
There is no module keyword — only usage
Section titled “There is no module keyword — only usage”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 modulemodule "bucket" { source = "./modules/gcs-bucket" bucket_name = "acme-app-logs"}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.
variable "bucket_name" { description = "Name of the GCS bucket to create" type = string}
variable "project_id" { description = "ID of the GCP project that owns the bucket" type = string}resource "google_storage_bucket" "this" { name = var.bucket_name project = var.project_id location = "US"}output "url" { description = "GS URI of the created bucket" value = google_storage_bucket.this.url}A caller wires this up with a module block, treating bucket_name and project_id as the inputs and module.<name>.url as the output it can reference elsewhere:
# root main.tfmodule "logs_bucket" { source = "./modules/gcs-bucket" bucket_name = "acme-app-logs" project_id = "acme-app-prod"}
output "logs_bucket_url" { value = module.logs_bucket.url}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 VPC network” and slowly grows an IAM binding here, a Compute Engine instance there, a Cloud Function 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 VPC network with public and private subnets,” not “all of our GCP 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
defaultinvariables.tfso 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.
variable "bucket_name" { description = "Name of the GCS bucket to create" type = string}
variable "force_destroy" { description = "Allow bucket to be destroyed even if it still contains objects" type = bool default = false}Here force_destroy has a sensible default — most callers never touch it — while bucket_name has none, because every caller genuinely needs a different bucket name and there is no reasonable default to guess.
flowchart LR
subgraph root["Root module"]
rmain["main.tf calls module.bucket"]
end
subgraph child["Child module: modules/gcs-bucket"]
vars["variables.tf (inputs)"] --> main["main.tf (resources)"]
main --> outs["outputs.tf (values exposed)"]
end
rmain -->|source = ./modules/gcs-bucket| vars
outs -->|module.bucket.url| rmain