Terraform modules, explained
Terraform modules
Build it once. Use it everywhere.
One pattern, many environments.
Words you'll meet
The copy-paste problem
What a module is
!p $ terraform plan
Inputs, resources, outputs
Writing a module
variable "name" {
type = string
description = "Prefix for resource names"
}
variable "cidr_block" {
type = string
default = "10.0.0.0/16"
}
resource "aws_vpc" "this" {
cidr_block = var.cidr_block
tags = { Name = "${var.name}-vpc" }
}
resource "aws_subnet" "private" {
count = 2
vpc_id = aws_vpc.this.id
cidr_block = cidrsubnet(var.cidr_block, 8, count.index)
}
output "vpc_id" {
value = aws_vpc.this.id
}
output "private_subnet_ids" {
value = aws_subnet.private[*].id
}
Calling a module
module "network" {
source = "./modules/network"
name = "shop-prod"
cidr_block = "10.20.0.0/16"
}
resource "aws_instance" "web" {
ami = "ami-0abc1234"
instance_type = "t3.small"
subnet_id = module.network.private_subnet_ids[0]
}
!p $ terraform init !m Initializing modules... !m - network in modules/network !g Terraform has been successfully initialized!
Wiring modules together
subnet_ids
Where modules live
source = "./modules/network"
source = "terraform-aws-modules/vpc/aws" version = "~> 5.0"
source = "git::https://github.com/acme/tf-modules.git//network?ref=v1.4.0"
One module, many environments
t3.micro × 1
m5.large × 3
module "bucket" {
source = "./modules/s3-bucket"
for_each = toset(["logs", "backups", "assets"])
name = "shop-${each.key}"
}
State and the moved block
aws_vpc.main
module.network.aws_vpc.this
vpc-0a1b2c3d10.0.0.0/16
new IDwould be created!r # aws_vpc.main will be destroyed !g # module.network.aws_vpc.this will be created !r Plan: 1 to add, 0 to change, 1 to destroy.
moved {
from = aws_vpc.main
to = module.network.aws_vpc.this
}!y # aws_vpc.main has moved to module.network.aws_vpc.this !g Plan: 0 to add, 0 to change, 0 to destroy.
Good habits
Recap
- A module is a folder: inputs in, resources built, outputs out.
- Modules talk only through outputs and inputs.
- Choose versions deliberately. Moved blocks keep things when addresses change.
- Keep each module focused. Build once, use everywhere.
Lesson map
Chapters
Hands-on reasoning
Practice lab
Build it for real: one module, two environmentsoptional · about 10 min
You'll build a real module and use it for two environments, then watch the moved-block trap happen for real.
terraform_data, which is built in, stands in for a real network.
You need: Terraform 1.4 or newer (install guide) or OpenTofu (install guide), and a terminal. No cloud account. No cost. Everything stays on your computer.
1. Make a folder called terraform-lab with these four files
variable "name" {
type = string
description = "Environment name, used as a prefix"
}
variable "cidr_block" {
type = string
default = "10.0.0.0/16"
}
variable "subnet_count" {
type = number
default = 2
}
# terraform_data is built into Terraform (1.4 and newer) and OpenTofu.
# Here it stands in for a real network, so the lab needs no cloud account and costs nothing.
resource "terraform_data" "network" {
input = {
name = "${var.name}-vpc"
cidr_block = var.cidr_block
}
}
resource "terraform_data" "subnet" {
count = var.subnet_count
input = cidrsubnet(var.cidr_block, 8, count.index)
}
output "network_name" {
value = terraform_data.network.output.name
}
output "subnet_ranges" {
value = terraform_data.subnet[*].output
}
module "dev" {
source = "./modules/network"
name = "dev"
}
module "prod" {
source = "./modules/network"
name = "prod"
cidr_block = "10.20.0.0/16"
}
output "dev_subnets" {
value = module.dev.subnet_ranges
}
output "prod_subnets" {
value = module.prod.subnet_ranges
}
2. Set it up
terraform init
You should see it find both module calls: - dev in modules/network and - prod in modules/network, then "successfully initialized".
3. Preview
terraform plan
Plan: 6 to add, 0 to change, 0 to destroy. A network and two subnets for each environment.
4. Build it, then look at the outputs
terraform apply
terraform output
Type yes when asked. Dev gets 10.0.0.0/24 and 10.0.1.0/24. Prod gets 10.20.0.0/24 and 10.20.1.0/24. That's cidrsubnet() at work.
5. Fix once, every environment benefits
In modules/network/variables.tf, change the subnet_count default from 2 to 3. Then:
terraform plan
Plan: 2 to add: one new subnet in dev and one in prod, from a single change. Run terraform apply to keep it.
6. The trap: rename a module call
In main.tf, rename module "dev" to module "development", and change module.dev.subnet_ranges to module.development.subnet_ranges. A renamed call counts as a new one, so set up again first:
terraform init
terraform plan
Plan: 4 to add, 0 to change, 4 to destroy. Terraform wants to destroy dev and rebuild it, just because the name changed. Don't apply this. (Skip the init and plan stops with "Module not installed".)
7. The fix: add a moved block to main.tf
moved {
from = module.dev
to = module.development
}
terraform plan
Four lines ending "has moved to module.development…", then Plan: 0 to add, 0 to change, 0 to destroy. Same objects, new address. Run terraform apply.
Keep this folder. Episode 2's lab carries on from here.
Tested step by step with OpenTofu 1.12.6 on 30 September 2026. The lab uses only features built into both tools. With OpenTofu, type tofu instead of terraform, and messages say "OpenTofu" instead of "Terraform". It could not be run with the Terraform program itself in the test environment.
How to use this lesson
Glossary. Tap any dotted-underlined term for a plain-English explanation. Playback pauses while you read.
Keyboard. Space or K plays/pauses, left and right arrows change chapter, and 1–4 answer a quick check.
Narration. The lesson currently uses your device's speech engine. The content is already segmented so recorded or cloned voice clips can replace it later without redesigning the lesson.