Interactive lesson

Terraform modules, explained

Lesson player Chapter 1

Terraform modules

Build it once. Use it everywhere.

One pattern, many environments.

Words you'll meet

ModuleA folder of Terraform code you can reuse.
Root moduleThe folder you run Terraform in.
InputA value you pass into a module.variable
OutputA value the module hands back.output

The copy-paste problem

modules/networkone copy of the code, called by all three
dev/
main.tf 240 lines
Firewall too open
Uses the module
staging/
main.tf 240 lines
copy of dev
Firewall too open
Uses the module
prod/
main.tf 240 lines
copy of dev
Firewall too open
Fixed here
Uses the module

What a module is

my-infra/root module
main.tfmodule "network" calls ↓
modules/
network/child module
variables.tf
main.tf
outputs.tf
 terminal, inside my-infra/
!p $ terraform plan
Child module = the recipe
Root module = the chef, picking recipes and ingredients

Inputs, resources, outputs

Inputs
namecidr_block
modules/network
variables.tf main.tf outputs.tf
aws_vpcaws_subnet ×2
Inside stays private. Only outputs can be seen.
Outputs
vpc_idsubnet_ids
Inputs in → resources built → outputs out

Writing a module

variables.tfmain.tfoutputs.tf
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
}
Anything not listed as an output stays private to the module.
10.0.0.0/1665,536 addresses
10.0.0.0/2410.0.1.0/24254 more
count.index 0 and 1 pick the first two slices

Calling a module

Root: main.tf the call
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]
}
Child: modules/network what it offers
Inputs it accepts
namecidr_block
Outputs it gives back
vpc_idprivate_subnet_ids
 terminal
!p $ terraform init
!m Initializing modules...
!m - network in modules/network
!g Terraform has been successfully initialized!

Wiring modules together

1
network
vpc_id
subnet_ids
2
compute
app_sg_id
3
database
accepts app servers only
Terraform reads the links and works out the build order: the dependency graph.
No reaching inside. Modules talk only through outputs and inputs.

Where modules live

Local folderwhile you build
source = "./modules/network"
Terraform Registryshared public modules
source  = "terraform-aws-modules/vpc/aws"
version = "~> 5.0"
Git repositorychoose a tag or commit with ?ref=
source = "git::https://github.com/acme/tf-modules.git//network?ref=v1.4.0"
Choose versions on purpose. The lock file doesn't record modules.
version = "~> 5.0"
4.9.05.0.05.4.25.9.16.0.0
Allowed: any 5.x. Never 6.

One module, many environments

dev

t3.micro × 1

Fix received
modules/app
prod

m5.large × 3

Fix received
Same code. Different inputs.
Local folder: fix lands on next apply. Versioned module: update dev's version first, then prod's.
module "bucket" {
  source   = "./modules/s3-bucket"
  for_each = toset(["logs", "backups", "assets"])
  name     = "shop-${each.key}"
}
module.bucket["logs"]module.bucket["backups"]module.bucket["assets"]

State and the moved block

Address in state:
aws_vpc.main module.network.aws_vpc.this
VPCvpc-0a1b2c3d10.0.0.0/16
would be destroyedkept: just moved
VPCnew IDwould be created
module.networkmodule
.aws_vpctype
.thisname
 terraform plan, no moved block (trimmed)
!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
}
 terraform plan, with moved block (trimmed)
!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

✓
One clear job per moduleA network module builds networks. Nothing else.
✓
Validate inputsA typo fails at once, with a clear message.
✓
Providers configured in the rootChildren inherit the settings, but still declare required_providers.
✕
Wrapping a single resourceA layer with no benefit.
✕
Nesting three deepHard to read, hard to debug.
Not reused and not simpler? Not a module.

Recap

Root modulewhere you run Terraform
inputs
child module
one clear job
outputs
  1. A module is a folder: inputs in, resources built, outputs out.
  2. Modules talk only through outputs and inputs.
  3. Choose versions deliberately. Moved blocks keep things when addresses change.
  4. Keep each module focused. Build once, use everywhere.

Press play for narration, animated diagrams and quick checks.

0:00 / 0:00

Lesson map

Chapters

    How our diagrams speak: Teal: focus or current concept Green: desired or successful Brick red: risk or conflict Slate blue: a relationship or link
    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

    modules/network/variables.tf
    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
    }
    
    modules/network/main.tf
    # 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)
    }
    
    modules/network/outputs.tf
    output "network_name" {
      value = terraform_data.network.output.name
    }
    
    output "subnet_ranges" {
      value = terraform_data.subnet[*].output
    }
    
    main.tf
    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.