A module is a directory containing .tf files. That is the entire definition — the
directory you have been working in is already one, the root module. Calling another directory from
it makes that directory a child module.
The shape
infra/
main.tf <- the root module
variables.tf
outputs.tf
versions.tf
modules/
network/ <- a child module
main.tf
variables.tf its inputs
outputs.tf its outputs — the ONLY things a caller can read
database/
service/Three files, and the split matters more in a module than in a root: variables.tf and
outputs.tf are the module's public API, and main.tf is the part callers
should never need to read.
Calling one
module "network" {
source = "./modules/network"
name_prefix = "pizza-tf"
vpc_cidr = "10.20.0.0/16"
tags = { Project = "terraform-tutorial" }
}
module "database" {
source = "./modules/database"
name_prefix = "pizza-tf"
vpc_id = module.network.vpc_id
private_subnet_ids = module.network.private_subnet_ids
}module.network.vpc_id is the reference form, and it works only because the network
module declares output "vpc_id". A module's resources are otherwise completely
invisible to its caller — there is no reaching inside for
module.network.aws_vpc.this.id.
That opacity is the point. The outputs are a contract, and anything not in them can be restructured without breaking callers.
⚠️ After adding or changing a source, run terraform init again. Terraform
records module sources during init, and a new module is otherwise reported as not installed.
Writing one
The network module from the stack this track deploys, cut down to its skeleton:
variable "name_prefix" {
type = string
description = "Prefix for every resource name, so a destroy is provably complete."
}
variable "vpc_cidr" {
type = string
default = "10.20.0.0/16"
description = "CIDR block for the VPC."
validation {
condition = can(cidrnetmask(var.vpc_cidr))
error_message = "vpc_cidr must be a valid CIDR block."
}
}
variable "tags" {
type = map(string)
default = {}
description = "Tags applied to every resource in this module."
}resource "aws_vpc" "this" {
cidr_block = var.vpc_cidr
enable_dns_support = true
enable_dns_hostnames = true
tags = merge(var.tags, { Name = var.name_prefix })
}output "vpc_id" {
description = "Id of the VPC."
value = aws_vpc.this.id
}
output "vpc_cidr" {
description = "CIDR of the VPC, for rules that allow VPC-internal traffic."
value = aws_vpc.this.cidr_block
}Three conventions worth adopting from this:
Take a name_prefix and a tags map. A module that
hard-codes names can only be called once — the second call collides on every name. Prefixing and
merging tags makes it reusable and makes teardown verifiable.
Name the single instance "this". The module is already called
network; naming its VPC aws_vpc.network stutters into
module.network.aws_vpc.network.
Do not put a provider block in a child module. Providers are
configured by the root and inherited. A module that configures its own cannot be used in a
configuration that needs a different region, and it cannot be removed cleanly — Terraform needs the
provider to exist in order to destroy what it made.
Module sources
module "local_example" {
source = "./modules/network"
}
module "registry_example" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.0" # only registry sources take `version`
name = "example"
}
module "git_example" {
# pinned to a tag. a branch name is not a version.
source = "git::https://github.com/example/terraform-modules.git//network?ref=v1.4.0"
}⚠️ Pin git sources to a tag or commit, never a branch. An unpinned source means
your infrastructure changes when someone else pushes, and the change arrives on whichever
init happens to re-fetch. The // in the git URL separates the repository
from a subdirectory within it.
count and for_each on modules
module "service" {
source = "./modules/service"
for_each = toset(["api", "worker"])
name_prefix = "pizza-tf-${each.key}"
}Same semantics as on a resource, including the reason to prefer for_each — and the
consequences are larger, because a shifted index destroys and recreates everything the module
contains.
The dependency cycle you will hit
This one is worth recognising by shape, because the error message names two modules and neither looks wrong.
In the stack this track deploys: the database module needs the application's security group, to write "allow MySQL from this group". The service module needs the database's endpoint, for the JDBC URL. Put the security group in either module and they reference each other:
Error: Cycle: module.database..., module.service...The fix is to hoist the shared value to whoever calls them both:
# in the ROOT module, not in either child
resource "aws_security_group" "app" {
name = "pizza-tf-app"
vpc_id = module.network.vpc_id
}
module "database" {
source = "./modules/database"
app_security_group_id = aws_security_group.app.id
}
module "service" {
source = "./modules/service"
app_security_group_id = aws_security_group.app.id
db_address = module.database.address
}A cycle between two modules is nearly always a value that belongs to their caller. Recognising that saves a lot of time.
⚠️ When not to write a module
The part most module advice leaves out.
Do not write one for a single resource. A module wrapping
aws_s3_bucket that passes six arguments straight through adds a layer, a version and an
indirection, and buys nothing. The resource was already the abstraction.
Do not write one until you have the second caller. A module designed from one use case gets its interface wrong, because you cannot see which parts are general. Duplicate it once, compare the two, and then extract — the differences tell you what the variables are.
Do not write one to organise a single stack. Splitting one root module into six
child modules that are each called once makes it harder to read, not easier. Multiple
.tf files in one directory are free and do the organising job perfectly.
The test: is this called more than once, or by more than one configuration? If not, it is a file, not a module.
The counterweight, honestly stated: the stack in this track has three modules, each called once. They earn their place because they are the boundaries the lessons are organised around — network, database, service — and because they are the units someone would plausibly replace. That is a teaching decision, and in a codebase you were only shipping, three files would be defensible.
Registry modules
The community VPC module handles IPv6, flow logs, VPN gateways and a dozen other things this track's does not, and it is battle-tested:
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.0"
name = "pizza-tf"
cidr = "10.20.0.0/16"
azs = ["us-west-2a", "us-west-2b"]
private_subnets = ["10.20.10.0/24", "10.20.11.0/24"]
public_subnets = ["10.20.0.0/24", "10.20.1.0/24"]
enable_nat_gateway = true
single_nat_gateway = true
}Use them in production. The trade is that a general-purpose module has a large surface — the VPC module has well over a hundred inputs — and when something is wrong you are debugging someone else's abstraction. This track writes its own because reading the whole thing is the lesson.
Next
Lesson 11 uses these modules to run dev, staging and prod off one codebase — and explains why workspaces are the wrong tool for production.