Modules
Reusable units of infrastructure as code.
Modules
A module is a directory of Terraform files used as a unit. The configs you write at the root are already a module — the root module. Reusable modules are how you stop pasting the same VPC + subnet + IGW into every project.
Calling a module
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.0"
name = "myapp-prod"
cidr = "10.0.0.0/16"
azs = ["us-east-1a", "us-east-1b", "us-east-1c"]
private_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
public_subnets = ["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"]
enable_nat_gateway = true
}
source is where the module's code lives. version pins it (only valid for registry / git-tag sources). The remaining keys map to the module's input variables.
Where source can point
| Source style | Example |
|---|---|
| Local path | ./modules/vpc |
| Terraform Registry | terraform-aws-modules/vpc/aws |
| GitHub | github.com/org/repo//path?ref=v1.0.0 |
| Git over SSH | git::ssh://git@github.com/org/repo.git//path?ref=v1.0.0 |
| HTTP | https://example.com/vpc.zip |
| S3 | s3::https://s3.amazonaws.com/bucket/vpc.zip |
The double slash (//) separates the repo from the path inside it. The ?ref=... pins to a tag, branch, or commit SHA.
Writing a module
A module is just a directory of .tf files with variable, resource, and output blocks:
modules/vpc/
main.tf # the actual resources
variables.tf # variable blocks declaring inputs
outputs.tf # output blocks exposing values
versions.tf # required_providers, required_version
README.md # what it does, how to use it
The convention is one file per concern, but everything's optional — Terraform reads every .tf file in the directory as one module.
A minimal modules/vpc/main.tf:
resource "aws_vpc" "this" {
cidr_block = var.cidr
tags = var.tags
}
resource "aws_subnet" "public" {
count = length(var.public_subnets)
vpc_id = aws_vpc.this.id
cidr_block = var.public_subnets[count.index]
availability_zone = var.azs[count.index]
map_public_ip_on_launch = true
}
modules/vpc/variables.tf:
variable "cidr" {
type = string
}
variable "azs" {
type = list(string)
}
variable "public_subnets" {
type = list(string)
}
variable "tags" {
type = map(string)
default = {}
}
modules/vpc/outputs.tf:
output "vpc_id" {
value = aws_vpc.this.id
}
output "public_subnet_ids" {
value = aws_subnet.public[*].id
}
Reading module outputs
Back in the parent:
module "vpc" {
source = "./modules/vpc"
cidr = "10.0.0.0/16"
...
}
resource "aws_instance" "web" {
subnet_id = module.vpc.public_subnet_ids[0]
}
module.<name>.<output> is the reference shape. Outputs declared as sensitive = true are exposed only as sensitive values — Terraform will refuse to use them in non-sensitive contexts.
Composition over branching
Inside a module, prefer composition to giant if/for_each ladders. A module called vpc should do one thing well. If you find yourself adding a var.also_create_a_database flag, the database belongs in a separate module the caller composes:
module "vpc" { source = "./modules/vpc" ... }
module "db" { source = "./modules/db" vpc_id = module.vpc.vpc_id }
When to extract a module
Three signals:
- You're about to copy/paste 20+ lines of resources between projects.
- The set of resources represents a coherent thing with a name (a "VPC", a "service stack", a "static site").
- The inputs that change between uses fit on one screen.
If any one of those is missing, just leave it as inline resources. Premature modules add the cost of indirection without paying it back.
Don't nest too deeply
Modules-calling-modules-calling-modules is debugging hell. Two layers (root → reusable module) is the sweet spot. Three (root → composite → primitive) is justifiable. Past that you're hiding the real config from yourself.