Modules
Package Terraform code into reusable, versioned modules - local modules, the registry, and how to design module interfaces
Modules
A module is just a directory of .tf files. The directory you've been running terraform apply in is already a module — the root module. The interesting part is calling one module from another to package and reuse infrastructure patterns.
Why Modules
- DRY. A VPC, an EKS cluster, an RDS database — these patterns repeat across environments. Define the pattern once.
- Interface. Modules expose only the inputs they want consumers to set, hiding the dozen internal resources.
- Versioning. Pin a module to a known-good version; upgrade deliberately.
- Review surface. A change to a shared module gets reviewed once and rolls out to every consumer.
Anatomy of a Module
modules/database/
├── main.tf # resources
├── variables.tf # inputs (the public API)
├── outputs.tf # outputs (also part of the public API)
├── versions.tf # required_providers
└── README.md # how to use itA minimal database module:
# modules/database/variables.tf
variable "name" {
type = string
description = "Identifier for the DB instance"
}
variable "vpc_id" {
type = string
}
variable "subnet_ids" {
type = list(string)
}
variable "instance_class" {
type = string
default = "db.t3.medium"
}
variable "engine_version" {
type = string
default = "16.2"
}
variable "allocated_storage" {
type = number
default = 20
}
variable "tags" {
type = map(string)
default = {}
}# modules/database/main.tf
resource "aws_db_subnet_group" "this" {
name = "${var.name}-subnets"
subnet_ids = var.subnet_ids
tags = var.tags
}
resource "aws_security_group" "this" {
name = "${var.name}-db"
vpc_id = var.vpc_id
tags = var.tags
}
resource "random_password" "master" {
length = 32
special = false
}
resource "aws_db_instance" "this" {
identifier = var.name
engine = "postgres"
engine_version = var.engine_version
instance_class = var.instance_class
allocated_storage = var.allocated_storage
db_subnet_group_name = aws_db_subnet_group.this.name
vpc_security_group_ids = [aws_security_group.this.id]
username = "admin"
password = random_password.master.result
skip_final_snapshot = true
tags = var.tags
}# modules/database/outputs.tf
output "endpoint" {
value = aws_db_instance.this.endpoint
}
output "security_group_id" {
value = aws_security_group.this.id
}
output "password" {
value = random_password.master.result
sensitive = true
}Calling a Module
# environments/production/main.tf
module "primary_db" {
source = "../../modules/database"
name = "myapp-prod"
vpc_id = aws_vpc.main.id
subnet_ids = aws_subnet.private[*].id
instance_class = "db.r6g.xlarge"
allocated_storage = 100
tags = local.common_tags
}
# Read module outputs
output "db_endpoint" {
value = module.primary_db.endpoint
}References across module boundaries look like module.NAME.OUTPUT_NAME.
Module Sources
The source argument decides where Terraform fetches the code from.
| Source | Example | When to use |
|---|---|---|
| Local path | source = "../../modules/database" | Modules inside your repo |
| Terraform Registry | source = "terraform-aws-modules/vpc/aws" | Public modules |
| Private Registry | source = "app.terraform.io/myorg/database/aws" | Internal modules via TFC/Enterprise |
| Git | source = "git::https://github.com/myorg/tf-modules.git//database?ref=v1.4.0" | Internal modules via plain git |
| S3 / GCS | source = "s3::https://s3.amazonaws.com/bucket/module.zip" | Air-gapped or restricted networks |
For Git and registry sources, always pin a version. Without ?ref=v1.4.0 or version = "~> 1.4" you'll get whatever's at HEAD — and your "no changes" PR will silently include the latest upstream changes.
Versioning Registry Modules
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.5" # any 5.5.x; bump deliberately to 5.6
name = "myapp-vpc"
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
single_nat_gateway = true
tags = local.common_tags
}Common version constraint operators:
| Constraint | Allows |
|---|---|
= 1.4.2 | exactly 1.4.2 |
>= 1.4.0 | 1.4.0 and above (dangerous; no upper bound) |
~> 1.4.0 | 1.4.x (patch updates) |
~> 1.4 | 1.x where x >= 4 (minor updates) |
Designing a Good Module Interface
A few rules that pay off:
- Small, opinionated modules beat big "do everything" modules. A
vpcmodule, adatabasemodule, aneks_clustermodule — composed in the root. - Required inputs should have no default. Optional inputs should. Force the caller to be explicit about identity (
name,environment), give them defaults for tuning (instance_class). - Validate inputs.
validationblocks catch typos at plan time, not 10 minutes into apply. - Output what you'd need to compose. IDs, ARNs, endpoints, security group IDs. Don't expose internals callers shouldn't reach into.
- Don't configure providers inside a reusable module. The caller does that. Modules that declare
provider {}blocks become hard to use in multi-region setups.
When NOT to Modularize
Premature modules cost more than copy-paste. Heuristics:
- One environment? Don't modularize yet.
- Two environments? Copy and adapt — see what actually differs.
- Three or more, and the differences are small? Now extract.
A module written before you understand the real variation tends to lock in the wrong interface.
What's Next
You can compose infrastructure from versioned, reusable pieces. Now learn the HCL features that make modules and resources scale — meta-arguments, expressions, and lifecycle controls → Advanced Patterns.