Terraform Modules
A module is the primary method for code reuse in Terraform. Rather than copy-paste a resource across configurations, you factor it into a module once and call it wherever you need it - passing the per-use differences as input variables.
Why modules exist
Say you deploy a web server - machine type, boot disk, static IP, service account - as one block of Terraform. Deploy several of the same kind and, without modules, the only option is to copy the block per server. Change one attribute later and you must find and update every copy by hand.
This is the problem general-purpose languages solve with functions: don't hand-copy a block,
name it once and reuse it. Terraform's equivalent is the module - factor the reusable code
into a module (say, named server) and every configuration that calls it picks up your changes.
It is the DRY principle (Don't Repeat Yourself): replace repetition with one abstraction.
- Unmanageable - each new resource makes the config bigger and harder to read.
- Error prone - every change has to be applied error-free across all copies; miss one and the duplicates drift apart.
- Inefficient - similar duplicated blocks cause discrepancies when they are updated.
What a module is
A module is a set of Terraform configuration files in a single directory - even one
directory with a single .tf file counts as a module. You reuse one by specifying its
source, which can be local (a directory inside your configuration) or remote (an
upstream module from the HashiCorp module registry, or your own).
Two near-identical VMs shouldn't mean copy-pasted config. Factor the resource into a
module - a reusable config in its own folder (instance/main.tf) - and call it once per
instance, passing the differences as input variables. Ordering is automatic: because the
firewall and both VM modules reference the network's self_link, Terraform builds the
network first, then everything that only depends on it in parallel.
- The module folder (
instance/) holds amain.tf; the parent calls it with amoduleblock:name,source = "./instance", plus the input variables. - Per-call differences are input variables -
var.name,var.zone,var.instance_type,var.subnetwork- declared invariableblocks inside the module. - A
variablewith adefaultuses that value when the caller omits it. The lab passes three of four inputs;instance_typefalls back to its default. - Values identical for every instance (e.g. the boot-disk image) stay hard-coded in the module, not made variables.
When to reach for a module
Not every configuration needs modules. Three situations make one worth the extra folder:
Benefits of modules
Write your first module
Authoring a module is just making a directory and putting .tf files in it - one directory
per module. To build a network module and a server module, create a network/ and a
server/ directory, each with its own main.tf (add outputs.tf/variables.tf as needed),
then fill each main.tf with the resources for that module. The two directories are two
modules.
Creating the directories and main.tf files only defines the modules. No infrastructure is
created until a root config calls them and you run terraform apply.
Calling a module (the source argument)
Once a module exists, you call it from the parent main.tf to reuse its code. A module
block references the module and passes it inputs; here the root config calls both a server
and a network module.
Every module block needs a source meta-argument - the value that tells Terraform
where the module's configuration code lives. That value is either a local path inside
your configuration or a remote path Terraform downloads.
source is a meta-argument, and it is the one argument every module block must have -
no module block is valid without it.
Where source can point
The value after source selects the module source type. Terraform supports several; this
course covers the first three.
| Source type | Address form | Example |
|---|---|---|
| Local path | starts with ./ or ../ | ./server |
| Terraform Registry | <NAMESPACE>/<NAME>/<PROVIDER> | terraform-google-modules/gcloud/google |
| GitHub | the repository URL | github.com/terraform-google-modules/terraform-google-vm |
| Bitbucket / HTTP URLs / Cloud Storage | provider URL | supported, not covered here |
Local path
A local path references a module stored in the same directory tree as the calling
module, and always starts with ./ or ../.
Local paths are unique among source types: they require no installation. The child module's files are referenced directly from the parent, so no explicit update is needed when the module changes - unlike remote sources, which Terraform has to download.
Terraform Registry
The Terraform Registry hosts a directory of publicly available modules for common
infrastructure components - load balancers, SQL instances, and so on - which are incredibly
useful for complex deployments (see the Registry catalog).
Its source address takes the form <NAMESPACE>/<NAME>/<PROVIDER>; for the Google provider that
is terraform-google-modules/<NAME>/google.
Pin a Registry module with the version argument. The version string lets Terraform
auto-upgrade to new patch releases while keeping a solid target, avoiding unwanted changes
to your configuration. Only modules installed from the Terraform Registry support the
version constraint - local and Git sources do not.
GitHub
After the Registry, the most common remote source is a GitHub repository: point source
straight at the repo URL.
Parameterize a module with input variables
Reusing a module with hardcoded attributes breaks the second you call it twice: if the
network name is fixed in the module's main.tf, calling that module for both a dev_network
and a prod_network collides on the same name and terraform plan/apply fails.
Variables let you customize a module without editing its source code - the same module, different values per environment (e.g. a small machine type in staging, a larger one in production). Parameterizing an argument takes three steps:
Unlike a root configuration - where you can supply variable values with -var,
.tfvars, TF_VAR_*, or a CLI prompt - you cannot pass values to a module's variables at
run time. A module's inputs are set only in the calling module block.
Wire modules together with output values
An argument that one module needs but another module creates can't be hardcoded either - for example, the server module needs the network name produced by the network module. To hand a resource attribute from one module to another, expose it as an output in the producer and accept it as a variable in the consumer, then wire them in the root config.
Re-run init after adding a module
terraform init downloads providers and installs modules. After you add a module
block - including each time a module is instantiated - you must run init again
before plan/apply, or Terraform won't know where the module lives.
Best practices
Where you need multiple copies of a value, loop with count or for_each rather than
hand-rolled scripting.
Modules keep your codebase DRY - but chasing zero duplication is a trap. Some duplication often makes a configuration more explicit and easier to visualize, so don't factor everything into a module just because you can.
In a custom network module the MTU and routing_mode are standardized, so they stay hardcoded - but the name must be a variable so the module is reusable. Focus parameters on the values you must change; if a value is always fixed in your environment, hardcoding it is fine.
Scenario: promoting across dev / staging / production
In a typical application, a new or modified feature is promoted development → staging →
production. Each environment holds the same resource types but differs in quantity.
Without modules, the developer builds and tests in development/, then manually copies the
code into staging/ and production/ - error-prone, and the environments drift.
The fix is to factor the reusable resources into a module (here servers/main.tf). Every
environment's main.tf references that one module, so a single change to the module
propagates to all of them - no manual copying. Each environment reuses the same module but
passes a different name and a different number of servers (num_vm).