CoursesInfrastructure as Code & automationModules: reusable infrastructure

Modules: reusable infrastructure

DRY infrastructure you can share.

Intermediate14 min · lesson 9 of 23

Copy-paste is how a Terraform codebase rots. You write a network block for dev, copy the file for staging, change two lines, copy it again for production. Three files describing one idea, drifting apart quietly, until the morning staging stops predicting what production will do. A module is the fix, and it works the way a recipe card works. Write the steps down once, leave blanks for the quantities, hand the card to anyone who needs to cook. In Terraform, a module is a folder of .tf files (the plain-text files Terraform reads) that takes inputs, builds real infrastructure out of them, and hands back a few outputs. You call it the way you call a function.

You are already using one. The folder you run terraform apply in is the root module. Anything it calls is a child module. Same language, same file types, same rules, nothing extra to install.

main.tf (the root module)
module "network" {
source = "./modules/network" # where the code lives: a folder in this repo
cidr = "10.0.0.0/16" # inputs: each must match a variable in the module
env = "prod"
}
module "web" {
source = "./modules/web"
subnet_id = module.network.private_subnet_id # an OUTPUT of one module...
env = "prod" # ...wired into the INPUT of another
}

Two things are happening in that file. The source argument says where the code comes from. Every other argument is an input, and it only works if the module declared a variable with that exact name. Those inputs describe a VPC (virtual private cloud, your own fenced-off network inside a cloud account) sized by a cidr value, written in CIDR notation (Classless Inter-Domain Routing, the 10.0.0.0/16 shorthand for a block of network addresses). Now read the second call again. module.network.private_subnet_id is a value the network module chose to publish, handed straight into the web module. Neither one knows anything about how the other is built, and that ignorance is the whole point.

The Contract Is What Reviewers Actually Read

A recipe card that says "add salt" is worse than one that says "add 6 grams of salt, and refuse the dish if someone hands you sugar". A module's variables are where you write the second kind. Each one can carry a type, a description, a default, and a validation block that rejects bad values before Terraform builds a plan at all. Every caller inherits those guardrails for free. That is what pays off in review: a reviewer reads six lines of inputs instead of sixty lines of resources, and the module itself polices what those six lines are allowed to say.

modules/network/variables.tf
variable "cidr" {
description = "Address range for the VPC, written in CIDR notation"
type = string
validation {
condition = can(cidrhost(var.cidr, 0))
error_message = "The cidr value must be in CIDR notation, for example 10.0.0.0/16."
}
}
variable "env" {
description = "Environment name. Ends up in tags and resource names."
type = string
validation {
condition = contains(["dev", "staging", "prod"], var.env)
error_message = "The env value must be one of: dev, staging, prod."
}
}
variable "enable_flow_logs" {
description = "Record network flow logs. Leave this on outside scratch environments."
type = bool
default = true
}

Outputs are the other half of the contract. Picture the serving hatch in a kitchen wall: whatever goes through the hatch belongs to the dining room, and everything else stays in the kitchen. Outputs are the only values a caller can read back, so they mark the seam between what the module owns and what the rest of your configuration is allowed to touch. Keep the list short and deliberate. Every output you add is a promise you have to keep in every future version.

modules/network/outputs.tf
output "private_subnet_id" {
description = "Subnet for workloads that must not be reachable from the internet"
value = aws_subnet.private.id
}
output "vpc_id" {
description = "ID of the VPC this module created"
value = aws_vpc.this.id
}

Now watch a typo hit that contract. Someone opens a pull request that misspells the environment.

terminal
terraform plan
output
│ Error: Invalid value for variable
│ on main.tf line 5, in module "network":
│ 5: env = "produciton"
│ The env value must be one of: dev, staging, prod.
│ This was checked by the validation rule at modules/network/variables.tf:15,3-13.

Terraform stopped while evaluating the configuration. No plan came out the other end, nothing was created, and no half-built network landed in production tagged with a nonsense environment name. The message points at the exact line that broke and the exact rule that caught it. Write validations for the inputs that do the most damage when they are wrong: environment names, address ranges, and any flag that decides whether something is reachable from the internet. Four lines each, and they stop the run instead of letting a bad value through.

Every Resource Gets A New Address

Terraform keeps a state file, a ledger that maps every resource in your code to the real thing it built in the cloud. Each entry in that ledger has an address. Move a resource into a module and the address changes: aws_subnet.private becomes module.network.aws_subnet.private. Same subnet in the real world, different name in the books.

terminal
terraform state list
output
module.network.aws_subnet.private
module.network.aws_subnet.public
module.network.aws_vpc.this
module.web.aws_instance.app
module.web.aws_security_group.web

Here is where the trap springs. The first time you refactor a working configuration into modules, Terraform reads the old address out of state, finds nothing with that address in the new code, and concludes the resource should be deleted. The new address has no state behind it, so it should be created. Your pure-refactor pull request now proposes to destroy production networking.

terminal
# You moved the VPC and the private subnet out of main.tf into modules/network/.
# Nothing about the real infrastructure changed. Terraform disagrees:
terraform plan
output
Terraform will perform the following actions:
# aws_subnet.private will be destroyed
# (because aws_subnet.private is not in configuration)
- resource "aws_subnet" "private" {
- cidr_block = "10.0.1.0/24" -> null
- id = "subnet-0a1b2c3d4e5f60718" -> null
- vpc_id = "vpc-0f1e2d3c4b5a69788" -> null
}
# aws_vpc.main will be destroyed
# (because aws_vpc.main is not in configuration)
- resource "aws_vpc" "main" {
- cidr_block = "10.0.0.0/16" -> null
- id = "vpc-0f1e2d3c4b5a69788" -> null
}
# module.network.aws_subnet.private will be created
+ resource "aws_subnet" "private" {
+ cidr_block = "10.0.1.0/24"
+ id = (known after apply)
+ vpc_id = (known after apply)
}
# module.network.aws_vpc.this will be created
+ resource "aws_vpc" "this" {
+ cidr_block = "10.0.0.0/16"
+ id = (known after apply)
}
Plan: 2 to add, 0 to change, 2 to destroy.

Read the counts, not the prose. "2 to add, 2 to destroy" on a change that was supposed to touch nothing is the alarm. The fix is a moved block, which works like a change-of-address card left at the post office. The house never went anywhere. Only the label on the envelope changed, and Terraform now knows the two labels name the same object.

main.tf
# Delete these once every environment's state has applied them.
moved {
from = aws_subnet.private
to = module.network.aws_subnet.private
}
moved {
from = aws_vpc.main
to = module.network.aws_vpc.this
}
terminal
terraform plan
output
Terraform will perform the following actions:
# aws_subnet.private has moved to module.network.aws_subnet.private
resource "aws_subnet" "private" {
id = "subnet-0a1b2c3d4e5f60718"
tags = {
"Name" = "prod-private-a"
}
# (11 unchanged attributes hidden)
}
# aws_vpc.main has moved to module.network.aws_vpc.this
resource "aws_vpc" "this" {
id = "vpc-0f1e2d3c4b5a69788"
tags = {
"Name" = "prod"
}
# (14 unchanged attributes hidden)
}
Plan: 0 to add, 0 to change, 0 to destroy.

Zero, zero, zero. That is what a refactor is supposed to look like. The older technique still works (terraform state mv aws_subnet.private module.network.aws_subnet.private), but it is a manual step someone has to remember, against every state file, at six on a Friday. A moved block lives in the code, gets read in the pull request, and runs itself inside CI (continuous integration, the automated pipeline that plans and applies your changes).

Where Modules Come From

The source argument takes a local path, a Git repository (Git being the version control system your code already lives in), or the public Terraform Registry, HashiCorp's index of published modules that anyone can call by name. A few other sources exist, such as an S3 bucket or a private registry. That choice is a trust decision wearing the costume of a string. Local code is yours and gets reviewed like the rest of your repository. Everything else is somebody else's code, running against your cloud account, holding your permissions, on your next plan.

main.tf
module "network" {
source = "./modules/network" # local: in this repo, reviewed in your PRs
cidr = "10.0.0.0/16"
env = "prod"
}
module "platform" {
# Git: pinned to a commit, not a branch and not a tag
source = "git::https://github.com/acme/tf-modules.git//platform?ref=9c3f1a2b7e4d05a8c61f3ba2d7e9018f4c5b6a72"
}
module "vpc" {
source = "terraform-aws-modules/vpc/aws" # public Terraform Registry
version = "5.8.1" # exact, not "~> 5.8"
name = "prod-vpc"
cidr = "10.0.0.0/16"
azs = ["eu-west-1a", "eu-west-1b"] # availability zones: separate
# datacentres inside one region
private_subnets = ["10.0.1.0/24", "10.0.2.0/24"]
enable_flow_log = true
}

Look at the two pins. The registry module names one exact release, 5.8.1, instead of a range. The Git module names a full 40-character commit hash, the fingerprint of one exact snapshot of that repository. A branch moves every time anyone pushes to it. A tag can be deleted and recreated an hour later pointing at different code, under the same name. The commit fingerprint is the one identifier nobody can repoint underneath you.

terminal
terraform init
output
Initializing the backend...
Initializing modules...
- network in modules/network
Downloading git::https://github.com/acme/tf-modules.git?ref=9c3f1a2b7e4d05a8c61f3ba2d7e9018f4c5b6a72 for platform...
- platform in .terraform/modules/platform/platform
Downloading registry.terraform.io/terraform-aws-modules/vpc/aws 5.8.1 for vpc...
- vpc in .terraform/modules/vpc
Initializing provider plugins...
- Finding hashicorp/aws versions matching "~> 5.0"...
- Installing hashicorp/aws v5.58.0...
- Installed hashicorp/aws v5.58.0 (signed by HashiCorp)
Terraform has created a lock file .terraform.lock.hcl to record the provider
selections it made above. Include this file in your version control repository
so that Terraform can guarantee to make the same selections by default when
you run "terraform init" in the future.
Terraform has been successfully initialized!

init is the step that fetches module code and unpacks it under .terraform/modules. It also downloads the provider plugins, the pieces that know how to talk to AWS or Azure or Cloudflare. That modules folder carries a small index written in JSON (JavaScript Object Notation, a plain-text way of writing structured data) recording what actually landed on disk. Reading it with jq, a command-line tool for pulling fields out of JSON, is the cheapest way to answer the question "which version is this pipeline running right now".

terminal
jq -r '.Modules[] | select(.Key != "") | [.Key, (.Version // "-"), .Source] | @tsv' \
.terraform/modules/modules.json
output
network - ./modules/network
platform - git::https://github.com/acme/tf-modules.git//platform?ref=9c3f1a2b7e4d05a8c61f3ba2d7e9018f4c5b6a72
vpc 5.8.1 registry.terraform.io/terraform-aws-modules/vpc/aws

Only registry modules record a resolved version there. For a Git module the pin lives inside the source string itself, which is one more argument for making that string a commit fingerprint you can paste into a browser and read end to end.

Where is this module's code coming from?
source = ...
Every module call answers this, and the answer decides how much you have to trust
./modules/network
Local path
Lives in your repo, reviewed in your pull requests. A version argument is not allowed here, because Git already versions it.
git::...?ref=<commit>
Git repository
Someone else's history. Pin ref to a full commit hash: branches move on every push, and tags can be deleted and recreated.
namespace/name/aws
Terraform Registry
Public code resolved at init time. Pin an exact version, because the lock file will not do it for you.
terminal
# Providers get locked with checksums. Do modules?
head -8 .terraform.lock.hcl
grep -c 'module' .terraform.lock.hcl
output
# This file is maintained automatically by "terraform init".
# Manual edits may be lost in future updates.
provider "registry.terraform.io/hashicorp/aws" {
version = "5.58.0"
constraints = "~> 5.0"
hashes = [
"h1:Ep7BjWRHtGBnSJ1eSJ1bWfxlvyRSTQmZTOX2dQeYWtE=",
0
The lock file does not pin your modules
.terraform.lock.hcl records provider versions with cryptographic checksums, and nothing else. Modules appear in it zero times. So a range like version = "~> 5.8" on a module block is reproducible by accident only: a fresh CI runner with an empty .terraform directory resolves the newest matching release, which may be code nobody on your team has read. Write exact versions for registry modules and full commit hashes for Git modules. Print the .terraform/modules/modules.json listing into your pipeline logs so you can prove after the fact what ran. And treat terraform init -upgrade as a deliberate change with a diff attached, never a reflex you reach for when something looks stale.

A Module Runs With Your Credentials

A module is code, and code off the internet is code off the internet. Two constructs inside one can run programs on the machine running Terraform. A data "external" block runs a program during terraform plan, before any human has approved anything. A provisioner with local-exec runs a command on that same machine during terraform apply. Both inherit the environment of whatever started Terraform, which on a build agent means cloud credentials, repository tokens, and every other variable the job exported. A data "http" block runs no program at all, but it makes an outbound request during plan, and one request is enough to carry a stolen token to an address of someone else's choosing.

The attacker's version of this is dull, which is exactly why it works. Publish a helpful-looking module under a namespace one character away from a popular one. Or land a pull request in a module some company already trusts, then wait for a pipeline to run plan on a schedule. Credentials leave in a single outbound request from a build agent, and that is traffic almost nobody inspects. Your first move as a defender is to read what init put on disk.

terminal
grep -rnE 'provisioner|"external"|"http"|local-exec|remote-exec' \
.terraform/modules/ --include='*.tf'
output
.terraform/modules/platform/platform/agent.tf:9: provisioner "local-exec" {
.terraform/modules/platform/platform/bootstrap.tf:3:data "http" "bootstrap" {
.terraform/modules/platform/platform/enroll.tf:14:data "external" "enroll" {

Three hits in code nobody on your team wrote. That is not proof of malice; plenty of honest modules shell out to do real work. It does mean a person has to read those three files before they go anywhere near production credentials. The controls that hold up around this: pin to reviewed commits; mirror external modules into a private registry or copy them into your own repository so upgrades arrive as reviewable pull requests; give the plan stage read-only cloud credentials that differ from the apply stage's; and block outbound traffic from build agents except to the handful of endpoints they genuinely need. Module supply chain gets a full lesson of its own later in this course.

Secrets Cross The Boundary Too

Outputs travel outward, and some of them carry things you would rather not shout across the room. If a module builds a database and returns a connection string, that string gets printed by terraform plan and terraform output, and pipeline logs are usually readable by far more people than the database is. Marking an output sensitive puts it in an envelope: Terraform swaps the value for a placeholder everywhere it prints.

modules/db/variables.tf + outputs.tf
# variables.tf
variable "admin_password" {
type = string
sensitive = true # anything derived from this is treated as sensitive too
}
# outputs.tf
output "connection_string" {
description = "Postgres URL for the application to consume"
value = "postgres://app:${var.admin_password}@${aws_db_instance.this.endpoint}/app"
sensitive = true
}
output "endpoint" {
description = "Host and port. Safe to print."
value = aws_db_instance.this.endpoint
}
outputs.tf (the root module)
output "db_connection_string" {
value = module.db.connection_string
sensitive = true # Terraform refuses to re-export it without this line
}
output "db_endpoint" {
value = module.db.endpoint
}

That second sensitive = true is not paperwork. Leave it off and Terraform fails the plan with "Output refers to sensitive values", because a secret escaping from a child module to the root is the moment it becomes visible to everyone. Making you type the intent is the feature.

terminal
terraform output
output
db_connection_string = <sensitive>
db_endpoint = "prod-app.cq7bz1x8pkld.eu-west-1.rds.amazonaws.com:5432"

That covers screens and logs. It does not cover storage.

terminal
terraform state pull | jq '.outputs.db_connection_string'
output
{
"value": "postgres://app:[email protected]:5432/app",
"type": "string",
"sensitive": true
}

The password is sitting in the state file in plain text, with "sensitive": true next to it as a display hint and nothing more. Encryption at rest and tight access control on your state backend are what actually protect that string. Better still, build the module so it never hands the secret back at all: output the name of the secrets-manager entry instead of the password inside it, and let the application fetch it with its own identity.

Providers Are Inherited, Until They Are Not

A provider configuration is the set of keys and the region a plugin works in, and child modules inherit the default one from whoever called them. That is why none of the examples above mention a region. The moment you keep two sets of keys on the ring, usually a second region for disaster recovery, inheritance stops guessing correctly and you have to hand each module the one you mean.

main.tf
provider "aws" {
region = "eu-west-1" # the default: inherited by child modules
}
provider "aws" {
alias = "dr"
region = "eu-central-1"
}
module "network" {
source = "./modules/network"
cidr = "10.0.0.0/16"
env = "prod"
}
module "network_dr" {
source = "./modules/network" # same module, second region
cidr = "10.1.0.0/16"
env = "prod"
providers = {
aws = aws.dr # pass the aliased provider explicitly
}
}

One rule saves real pain later: a module meant to be called by other modules should never declare its own provider block. Terraform needs a provider configuration in order to destroy resources, so if that configuration lives inside the module you are trying to delete, you end up in a state you cannot cleanly plan your way out of. A module carrying its own provider block also cannot be used with count, for_each, or depends_on. Configure providers in the root module and pass them down.

Quick check
01You pin a registry module with version = "~> 5.8" and commit .terraform.lock.hcl. Six weeks later a clean CI runner runs terraform init && terraform plan, and the plan shows changes nobody wrote. What is the most likely cause?
Correct — Modules do not appear in .terraform.lock.hcl at all, and a fresh runner with no .terraform directory takes the newest release matching the range.
Incorrect — There are no module hashes in that file to refresh, and -upgrade loosens things further by resolving to even newer matching versions.
Incorrect — A clean runner has no cache. A cache would also hold you on the older version rather than introduce new changes.
Incorrect — That constraint sits on the module block and governs the module only. Provider selection is the one thing the lock file does pin, with checksums.
02You call the same ./modules/network module twice: once for the default region and once for disaster recovery, using a second provider "aws" block with alias = "dr". How does the DR (disaster recovery) module call get the right region?
Incorrect — a child module inherits the default provider, and aliased providers are never auto-matched by region.
Incorrect — region belongs on the provider configuration, not passed as a module variable in this design.
Incorrect — a module meant to be called by others should never declare its own provider block, or it cannot be cleanly destroyed and cannot use count/for_each/depends_on.
Correct — inheritance stops guessing correctly once you keep two sets of keys on the ring, so you hand each module the aliased provider you mean.
03You moved a VPC and its private subnet out of main.tf into modules/network/ with no change to the real infrastructure. terraform plan now reports 'Plan: 2 to add, 0 to change, 2 to destroy'. What is the correct fix?
Incorrect — applying would destroy production networking; a destroy-then-create of real resources is an outage and possible data loss.
Correct — a moved block is a change-of-address card that lives in code, is read in the pull request, and runs itself in CI.
Incorrect — prevent_destroy only makes the plan error out; it never remaps the resource to its new address.
Incorrect — deleting state throws away every resource mapping and is far more dangerous than the problem you started with.

Worth doing on your largest Terraform repository this afternoon: run terraform init, then that jq line against .terraform/modules/modules.json, and look at every row whose source is not a local path. Each one should name an exact version or a commit fingerprint you could open in a browser and read end to end. Any row that fails that test is code you never agreed to run, running anyway, on the next plan.

Try this

Run terraform plan on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.

Takeaway

The trap worth remembering here: the lock file does not pin your modules. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related