Terraform Basics #8 Module Basics: Reusing Resource Bundles and Registry Modules
The code that started as a single bucket has grown through this series into instances, subnets, and security groups. Keep going like this with real production infrastructure and main.tf becomes hundreds of lines — and the moment a requirement like “the same setup twice, once for dev and once for prod” arrives, copy-paste begins. It is the same situation as programming with one long script and no functions. In Terraform, the equivalent of a function is the module. It is the unit for naming a bundle of resources and reusing it, and in this post we will build one ourselves, pull one from the registry, and work out the criteria for splitting them.
Every directory is already a module #
A module is not a special file format — it is a directory containing .tf files. That means the directory you have been working in all along is already a module, and Terraform calls it the root module. The module’s interface is also something you have already learned. The variables from #3 are the module’s inputs, and outputs are its outputs. Back then they were channels for a human to put values in and read results out, but once modules connect to each other, they become a function signature: the caller passes values into variables and takes results back through outputs. The only new thing to learn is the call syntax.
Building a child module #
Say a static-website bucket setup (bucket + versioning + public access block) keeps repeating across projects. We extract that bundle into a module. By convention, it goes under modules/.
modules/
└── static-bucket/
├── variables.tf # inputs
├── main.tf # resources
└── outputs.tf # outputs# modules/static-bucket/variables.tf
variable "bucket_name" {
type = string
description = "Name of the bucket to create"
}
# modules/static-bucket/main.tf
resource "aws_s3_bucket" "this" {
bucket = var.bucket_name
}
resource "aws_s3_bucket_versioning" "this" {
bucket = aws_s3_bucket.this.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_public_access_block" "this" {
bucket = aws_s3_bucket.this.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
# modules/static-bucket/outputs.tf
output "bucket_arn" {
value = aws_s3_bucket.this.arn
}Naming resources this inside a module is a community convention. The module’s name already says what the role is, so the resources inside do not get a role name of their own again. Calling the module is done with a module block.
# main.tf of the root module
module "assets" {
source = "./modules/static-bucket"
bucket_name = "my-assets-prod"
}
module "logs" {
source = "./modules/static-bucket"
bucket_name = "my-logs-prod"
}
output "assets_arn" {
value = module.assets.bucket_arn
}source is where the module lives, and the remaining arguments go into the module’s variables. Calling the same module twice with different values produced two bucket setups. A module’s outputs are referenced as module.NAME.OUTPUT. One procedural caveat: whenever you add a module or change its source, you must run terraform init again. init is the step that installs modules, and the answer to a “Module not installed” error is almost always init.
Registry modules: look before you build #
You do not have to build every module yourself. The Terraform Registry hosts public modules the community has refined over time, and the terraform-aws-modules family in particular is the de facto standard collection for AWS setups. VPC is the flagship example.
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 6.0"
name = "main"
cidr = "10.0.0.0/16"
azs = ["ap-northeast-2a", "ap-northeast-2c"]
public_subnets = ["10.0.1.0/24", "10.0.2.0/24"]
private_subnets = ["10.0.11.0/24", "10.0.12.0/24"]
}A configuration that creates dozens of resources — subnets, route tables, NAT gateways — is done in a few lines of input. The one rule you must follow with registry modules is pinning version. Unlike providers, modules work even without a version, but then a new version someone else publishes can change your infrastructure’s plan without warning. When the practice series gets to VPC, we will first build it from raw resources to understand how it works, then compare it against the module. Convenience comes with the cost of a black box, and only someone who knows the fundamentals can keep that cost under control.
Criteria for splitting modules #
Once you learn modules, everything starts looking like a module — but over-splitting is as harmful as repetition. A module wrapping a single resource is just a wrapper that forwards arguments, adding code for nothing; at the other extreme, stuffing the entire infrastructure into one giant module erases any unit of reuse. Two criteria hold up in practice.
- Make a module when the repetition has actually appeared at least twice. Modules built in advance for imagined future reuse usually get the interface wrong.
- Cut along units that are created together and destroyed together. Bundles that share a lifecycle — “the static-site bucket set,” “one service’s network” — make good modules, while putting things with different lifespans (a VPC and the applications on top of it) into one module means even a small change produces a large plan.
Using these criteria, we will do a hands-on refactoring in the practice series, structuring the whole infrastructure into modules.
Recap #
Here is what we covered in this post.
- A module is a directory of
.tffiles, and your working directory so far is already the root module. variables are inputs, outputs are outputs - Child modules are called with a
moduleblock and their results read viamodule.NAME.OUTPUT. Adding a module requires an init - Naming resources inside a module
thisis the convention - The registry’s terraform-aws-modules family is a battle-tested standard collection, and modules must always have version pinned
- Split modules when repetition actually appears, along units that share a lifecycle
The next post (#9 Remote State and Collaboration) is the final part of the basics series. We will finish the homework deferred in #5: moving state from a laptop’s local disk to an S3 backend the team shares, adding locking, and completing the preparation for collaboration.