Skip to main content

Terraform Modules

Exam guide§2.4

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.

++[...]+Web ServerOriginal codeWeb ServerManual copyWeb ServerManual copy
Every extra web server means another hand-copied, hand-edited block: the copies drift apart and one change must be repeated error-free everywhere.

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.

FactsWhat copy-paste costs you
  • 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).

-- main.tf-- instance/-- main.tf-- variables.tf-- outputs.tfroot modulemodule
Modules nest: main.tf on its own is the root module (the directory you run terraform in), and the whole instance/ subdirectory - main.tf, variables.tf, outputs.tf together - is a child module you reuse.

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.

google_compute_networkcreated FIRST - others depend on itfirewall + VMs reference network.self_linkgoogle_compute_firewallallow 22 / 80 / 3389 / icmpVM instance #1module "instance" - call AVM instance #2module "instance" - call B↑ these three build in PARALLEL once the network exists; VMs #1 and #2 share one module
One instance module, called twice; both VMs plus the firewall wait on the network
FactsHow the module works
  • The module folder (instance/) holds a main.tf; the parent calls it with a module block: name, source = "./instance", plus the input variables.
  • Per-call differences are input variables - var.name, var.zone, var.instance_type, var.subnetwork - declared in variable blocks inside the module.
  • A variable with a default uses that value when the caller omits it. The lab passes three of four inputs; instance_type falls 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:

Modularize codeWhen you want to organize yourTerraform config into modules soit stays readable and manageable.Eliminate repetitionWhen a fair bit of code isrepeated multiple times.Standardize resourcesWhen a set of resources mustbe created in a specific way.
Three situations where a module earns its keep: organizing sprawling config, cutting repeated blocks, and locking a resource into a standard shape.

Benefits of modules

ReadableModules replace manylines of code with a callto the source module.ReusableWrite code once andreuse it multiple timesacross environments.AbstractSeparate configs intological units, reducingdependency for debugging.ConsistentPackage a set of resourceconfigs for consistentreplication.
Four payoffs of factoring config into modules: fewer lines to read, write-once reuse, logical separation, and consistent replication.

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.

-- network/-- main.tf-- outputs.tf-- variables.tfNM-- server/-- main.tf-- outputs.tf-- variables.tfSMCreate directoriesEnter the code in thenetwork main.tf file tocreate a custom networkEnter the code in theserver main.tf file tocreate a virtual serverWe created two modules!NMSMresource "google_compute_network" "dev_network" { name = "mynetwork" auto_create_subnetworks = true routing_mode = "global" mtu = 1460}resource "google_compute_firewall" "default" { # All necessary parameters defined}resource "google_compute_instance" "server_VM" { # All necessary parameters defined}
Authoring two modules: create a directory per module (network/ and server/), each with its own main.tf, then fill each main.tf with the resources for that module - N marks the network directory and M its main.tf; S and M the server module.
GotchaWriting a module doesn't build anything

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.

-- network/-- main.tf-- outputs.tf-- variables.tf-- server/-- main.tf-- outputs.tf-- variables.tf-- main.tfNSMRoot main.tfMprovider "google" {region = us-central-1}module "web_server" {source = "./server"S}module "server_network" {source = "./network"N}
The root main.tf (M) calls each child module by pointing its source at the module directory: the web_server block reuses server/ (S), the server_network block reuses network/ (N). The badges and colors map each source line to its directory.

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.

module "<NAME>" {
source = "<source_location>"
# [CONFIG ...]
}
Gotcha`source` is mandatory

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 typeAddress formExample
Local pathstarts with ./ or .././server
Terraform Registry<NAMESPACE>/<NAME>/<PROVIDER>terraform-google-modules/gcloud/google
GitHubthe repository URLgithub.com/terraform-google-modules/terraform-google-vm
Bitbucket / HTTP URLs / Cloud Storageprovider URLsupported, 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 ../.

GotchaLocal modules need no install

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.

module "web_server" {
source = "terraform-google-modules/vm/google//modules/compute_instance"
version = "0.0.5"
}
Gotcha`version` works only for Registry modules

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.

module "VMserver" {
source = "github.com/terraform-google-modules/terraform-google-vm//modules/compute_instance"
}

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.

-- main.tf-- network/-- main.tf-- outputs.tf-- variables.tfMNmodule "dev_network" {source = "./network"}module "prod_network" {source = "./network"}Source main.tfresource "google_compute_network" "vpc_network"{name = "my-network"}Name conflict errorsError: Error creating Network:Error 409: The resource'projects/<project-id>/global/networks/mynetwork' already exists.
Hardcoding the network name in the module: the root main.tf calls the same ./network module as both dev_network and prod_network, but the module's vpc_network resource has name hardcoded to my-network, so the second apply fails with a 409 name-conflict error.

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:

Replace the hardcodedarguments with a variable.# network/main.tfresource "google_compute_network" "vpc_network"{ name = var.network_name ..}Declare the variablesin the variables.tf file.# network/variables.tfvariable "network_name" { type = string description = "name of the network"}Pass the value for theinput variable whenyou call the module.# root main.tfmodule "dev_network" { source = "./network" network_name = "my-network1"}module "prod_network" { source = "./network" network_name = "my-network2"}
Parameterizing a module argument in three steps, each showing the file it edits: replace the hardcoded value with var.network_name in the module's main.tf, declare the variable in variables.tf, then pass a value in each module block from the root main.tf so one module serves both dev and prod.
GotchaModule variables can't be set at run time

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.

-- main.tf-- network/-- main.tf-- outputs.tf-- variables.tf-- server/-- main.tf-- outputs.tf-- variables.tfNSresource "google_compute_network" "my_network"name = "mynetwork"auto_create_subnetworks = truerouting_mode = "GLOBAL"Nresource "google_compute_instance" "server_VM"network = <created by network module># all necessary parameters definedSThe server module needsthe network name createdby the network module.
The problem output values solve: the network module creates the network name, and the server module needs that exact name for its network argument - so the value has to be passed out of one module and into the other.
Declare the output valuein the network module.# network/outputs.tfoutput "network_name" { value = google_compute_network.my_network.name}Declare the argument as avariable in the server module.# server/variables.tfvariable "network_name" {}# server/main.tfresource "google_compute_instance" "server_VM" { network = var.network_name}Refer the output valuewhen calling theserver module.# root main.tfmodule "server_VM1" { source = "./server" network_name = module.my_network_1.network_name}module "my_network_1" { source = "./network"}
Passing a value between modules in three steps, each showing the file it edits: declare the output in network/outputs.tf, accept it as a variable in the server module, then wire it in the root main.tf with module.my_network_1.network_name.

Re-run init after adding a module

Gotcha`init` isn't one-and-done

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

01Modularize your code for keeping your codebase DRY andencapsulating best practices.02Parameterize modules intelligently only if they make sensefor end users to change.03Use local modules to organize and encapsulate your code.04Use the public Terraform Registry for complementingcomplex architecture confidently.05Publish and share your module with your team.
Five module best practices, numbered in the order the course presents them.

Where you need multiple copies of a value, loop with count or for_each rather than hand-rolled scripting.

GotchaDon't over-modularize

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.

DECISIONParameterize a value, or hardcode it?

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.

Pick this when: parameterize only what an end user should change; hardcode anything fixed for your environment

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.

+Feature testNew resourcesare added</>DDevelopmentFeature istested in dev.Code is approved</>SStaging</>PProductionThe code is manually copiedacross staging and production.
A change is tested in Development; only after approval is the same code promoted to Staging and Production. Without modules, that promotion is a manual copy.

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).

-- servers/-- main.tf-- environments/-- development/-- main.tf-- staging/-- main.tf-- production/-- main.tfMDSPMresource "google_compute_instance" "serverVM" { #All necessary parameters defined}resource "google_compute_address" "static_ip" { #All necessary parameters definedmodule "dev-server" { source = "../../main.tf" name = "dev_server" num_vm = 2}Mmodule "prod-server" { source = "../../main.tf" name = "prod_server" num_vm = 4}Mmodule "stag-server" { source = "../../main.tf" name = "stag_server" num_vm = 6}M
One module (servers/main.tf, M) is reused by every environment's main.tf: development (D), staging (S) and production (P) each call the same module source, passing a different name and num_vm.