Modules: reusable infrastructure
DRY infrastructure you can share.
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.
module "network" {source = "./modules/network" # where the code lives: a folder in this repocidr = "10.0.0.0/16" # inputs: each must match a variable in the moduleenv = "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.
variable "cidr" {description = "Address range for the VPC, written in CIDR notation"type = stringvalidation {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 = stringvalidation {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 = booldefault = 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.
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.
terraform plan
╷│ 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.
terraform state list
module.network.aws_subnet.privatemodule.network.aws_subnet.publicmodule.network.aws_vpc.thismodule.web.aws_instance.appmodule.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.
# 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
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.
# Delete these once every environment's state has applied them.moved {from = aws_subnet.privateto = module.network.aws_subnet.private}moved {from = aws_vpc.mainto = module.network.aws_vpc.this}
terraform plan
Terraform will perform the following actions:# aws_subnet.private has moved to module.network.aws_subnet.privateresource "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.thisresource "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.
module "network" {source = "./modules/network" # local: in this repo, reviewed in your PRscidr = "10.0.0.0/16"env = "prod"}module "platform" {# Git: pinned to a commit, not a branch and not a tagsource = "git::https://github.com/acme/tf-modules.git//platform?ref=9c3f1a2b7e4d05a8c61f3ba2d7e9018f4c5b6a72"}module "vpc" {source = "terraform-aws-modules/vpc/aws" # public Terraform Registryversion = "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 regionprivate_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.
terraform init
Initializing the backend...Initializing modules...- network in modules/networkDownloading git::https://github.com/acme/tf-modules.git?ref=9c3f1a2b7e4d05a8c61f3ba2d7e9018f4c5b6a72 for platform...- platform in .terraform/modules/platform/platformDownloading registry.terraform.io/terraform-aws-modules/vpc/aws 5.8.1 for vpc...- vpc in .terraform/modules/vpcInitializing 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 providerselections it made above. Include this file in your version control repositoryso that Terraform can guarantee to make the same selections by default whenyou 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".
jq -r '.Modules[] | select(.Key != "") | [.Key, (.Version // "-"), .Source] | @tsv' \.terraform/modules/modules.json
network - ./modules/networkplatform - git::https://github.com/acme/tf-modules.git//platform?ref=9c3f1a2b7e4d05a8c61f3ba2d7e9018f4c5b6a72vpc 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.
# Providers get locked with checksums. Do modules?head -8 .terraform.lock.hclgrep -c 'module' .terraform.lock.hcl
# 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
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.
grep -rnE 'provisioner|"external"|"http"|local-exec|remote-exec' \.terraform/modules/ --include='*.tf'
.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.
# variables.tfvariable "admin_password" {type = stringsensitive = true # anything derived from this is treated as sensitive too}# outputs.tfoutput "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}
output "db_connection_string" {value = module.db.connection_stringsensitive = 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.
terraform 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.
terraform state pull | jq '.outputs.db_connection_string'
{"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.
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 regioncidr = "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.
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.