Table of contents
- Why Modules
- Basic Module Structure
- Calling a Module
- source — Where to Get Modules From
- Making Good Use of the Official Registry
- Calling Modules Within Modules
- Module Best Practices
- Creating a Module Registry
Why Modules
One VPC, three subnets, a few security groups, one RDS. Once you build this combination for the dev environment, you need the exact same thing for staging and the exact same thing for production. What’s wrong with just copy-pasting the same HCL code for each environment?
- You have to make changes three times: Changing a single security group rule means touching three places simultaneously
- Divergence happens: Forgetting to update one of the three copies is inevitable
- Reviews are painful: When the same change appears three times in a PR, it’s hard to verify with human eyes
Modules solve these problems. The concept is like calling a well-crafted infrastructure pattern as a function. It’s identical to functions in programming. They take inputs, create resources through internal logic, and return outputs.
flowchart LR
Input["Input variables\n(variables)"] --> Module["Module\n(Creates internal resources)"]
Module --> Output["Output values\n(outputs)"]
Input -.->|"cidr_block\nenvironment\ntags"| Module
Module -.->|"vpc_id\nsubnet_ids\nsecurity_group_id"| Output
Basic Module Structure
A module is nothing special, really. Any directory containing .tf files is a module. By convention, they’re typically split into three files.
modules/vpc/
├── main.tf # Actual resource definitions
├── variables.tf # Input variables
└── outputs.tf # Output values
Let’s create a simple VPC module.
# modules/vpc/variables.tf
variable "cidr_block" {
description = "VPC CIDR block"
type = string
}
variable "environment" {
description = "Environment name (dev, staging, prod)"
type = string
}
variable "azs" {
description = "List of availability zones to use"
type = list(string)
default = ["ap-northeast-2a", "ap-northeast-2c"]
}
variable "tags" {
description = "Common tags"
type = map(string)
default = {}
}
Inputs are declared with variable blocks. Specifying the type, description, and default value prevents confusion when reusing later.
# modules/vpc/main.tf
resource "aws_vpc" "this" {
cidr_block = var.cidr_block
enable_dns_hostnames = true
enable_dns_support = true
tags = merge(
var.tags,
{
Name = "${var.environment}-vpc"
Environment = var.environment
}
)
}
resource "aws_subnet" "public" {
count = length(var.azs)
vpc_id = aws_vpc.this.id
cidr_block = cidrsubnet(var.cidr_block, 8, count.index)
availability_zone = var.azs[count.index]
tags = merge(
var.tags,
{
Name = "${var.environment}-public-${count.index + 1}"
Tier = "public"
}
)
}
There’s a convention of naming resources this or main. Externally, they’re referenced as module.vpc.aws_vpc.this, and since the module name itself provides context, the resource name is kept concise.
# modules/vpc/outputs.tf
output "vpc_id" {
description = "Created VPC ID"
value = aws_vpc.this.id
}
output "public_subnet_ids" {
description = "List of public subnet IDs"
value = aws_subnet.public[*].id
}
output "cidr_block" {
description = "VPC CIDR"
value = aws_vpc.this.cidr_block
}
Outputs selectively expose only the values that the outside world needs from what the module created. They are the module’s “public API.”
Calling a Module
Now let’s use this module.
# envs/dev/main.tf
module "vpc" {
source = "../../modules/vpc"
cidr_block = "10.0.0.0/16"
environment = "dev"
azs = ["ap-northeast-2a", "ap-northeast-2c"]
tags = {
Project = "my-service"
Owner = "platform-team"
}
}
resource "aws_instance" "app" {
ami = "ami-0c9c942bd7bf113a2"
instance_type = "t3.micro"
subnet_id = module.vpc.public_subnet_ids[0] # Reference module output
}
Call it with a module block, specifying the module location with source. Access the module’s exposed output values using module.<name>.<output_name>.
When using a module for the first time or when source changes, you need to run terraform init again. Terraform needs to download or link the module.
source — Where to Get Modules From
Modules can be sourced from many places: local directories, Git repositories, the Terraform registry, S3, HTTP, etc. Each serves a different purpose.
flowchart TB
Src["source specification"]
Src --> Local["Local path\n'../../modules/vpc'"]
Src --> Git["Git repo\n'git::https://...'"]
Src --> Reg["Registry\n'terraform-aws-modules/vpc/aws'"]
Src --> S3["S3/GCS\n's3::https://...'"]
Local -.advantage.-> LocalPro["Fast iteration,\nsame repo"]
Git -.advantage.-> GitPro["Version tags,\nteam/external sharing"]
Reg -.advantage.-> RegPro["Official/verified modules,\nSemVer"]
Local path — When using modules within the same repo
module "vpc" {
source = "../../modules/vpc"
}
The simplest approach. Suitable for teams developing or managing within a single repo.
Git repo — When sharing internal modules
module "vpc" {
source = "git::https://github.com/my-company/terraform-modules.git//vpc?ref=v1.2.0"
}
// specifies a subdirectory within the repo. ref can be a tag, branch, or commit hash. It’s strongly recommended to pin versions using tags. Setting ref=main means your infrastructure can change without warning whenever the module is updated.
For SSH access:
module "vpc" {
source = "git::ssh://git@github.com/my-company/terraform-modules.git//vpc?ref=v1.2.0"
}
Terraform Registry — Official or public modules
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.5.0"
name = "my-vpc"
cidr = "10.0.0.0/16"
# ...
}
registry.terraform.io has verified public modules. The terraform-aws-modules family in particular are semi-official modules maintained by the AWS team and can be trusted. You can apply SemVer constraints with version.
| Version Notation | Meaning |
|---|---|
"5.5.0" | Exactly this version |
"~> 5.5" | Latest within 5.5.x (not 5.6.0) |
"~> 5.5.0" | Latest within 5.5.x (5.5.1 OK, 5.6.0 no) |
">= 5.0, < 6.0" | All of 5.x |
Making Good Use of the Official Registry
No need to reinvent the wheel. The registry already has plenty of well-built modules.
Frequently used in AWS environments:
terraform-aws-modules/vpc/aws— Handles VPC, subnets, NAT, routing all in oneterraform-aws-modules/eks/aws— EKS cluster + node groupsterraform-aws-modules/rds/aws— RDS instance, subnet groups, parameter groupsterraform-aws-modules/security-group/aws— Security group rule management
For example, creating a VPC with the official module takes about this much code.
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.5"
name = "prod-vpc"
cidr = "10.0.0.0/16"
azs = ["ap-northeast-2a", "ap-northeast-2c"]
private_subnets = ["10.0.1.0/24", "10.0.2.0/24"]
public_subnets = ["10.0.101.0/24", "10.0.102.0/24"]
enable_nat_gateway = true
single_nat_gateway = true
tags = {
Environment = "prod"
}
}
Written by hand, this would have exceeded 200 lines. With the module, you just pass the required inputs.
That said, blindly using official modules isn’t always the best approach. Official modules have many options for versatility, making them complex. If your requirements are simple, writing it yourself can be clearer. Also, when official modules change their internal structure, state migration may be required. Choose based on project scale and team maturity.
Calling Modules Within Modules
Modules can call other modules. Nesting is possible.
modules/
├── network/
│ ├── main.tf # Uses module "vpc"
│ └── ...
└── vpc/
└── ...
# modules/network/main.tf
module "vpc" {
source = "../vpc"
cidr_block = var.cidr_block
environment = var.environment
}
resource "aws_internet_gateway" "main" {
vpc_id = module.vpc.vpc_id
}
Excessive nesting makes debugging difficult. About two levels deep is generally manageable.
Module Best Practices
These are principles refined through real-world usage.
1) Each module should represent a single logical unit
A VPC module should contain VPC, subnets, route tables, NAT, and other things that “make up a VPC.” Adding RDS to it blurs the module’s responsibility.
2) Always add types and descriptions to input variables
variable "instance_type" {
description = "EC2 instance type (e.g., t3.micro, m5.large)"
type = string
default = "t3.micro"
validation {
condition = can(regex("^(t3|t4g|m5|m6i)\\.", var.instance_type))
error_message = "Allowed instance families: t3, t4g, m5, m6i"
}
}
Using validation blocks to verify input values catches module users’ mistakes early.
3) Don’t expose internal resource names externally
Module users just need module.vpc.vpc_id to get the VPC ID. They don’t need to know how resources are composed internally. Only expose what’s necessary through outputs.
4) Include a README.md and examples
modules/vpc/
├── main.tf
├── variables.tf
├── outputs.tf
├── README.md # Usage, input/output descriptions
└── examples/
├── basic/
│ └── main.tf # Minimal usage example
└── complete/
└── main.tf # Example with all options
Documentation is essential when others will use the module. Tools like terraform-docs can auto-generate the README.
terraform-docs markdown table ./modules/vpc > ./modules/vpc/README.md
5) Practice proper version management
Apply SemVer to modules. Tagging with Git tags like v1.0.0, v1.1.0 lets users pin versions reliably.
- MAJOR: Breaking changes (removing outputs, adding required inputs, etc.)
- MINOR: Backward-compatible feature additions (adding inputs with defaults, etc.)
- PATCH: Bug fixes
6) Consider separate modules instead of deep conditional branching
If a single module ends up with var.environment == "prod" ? X : Y branches scattered everywhere, it might be better to split into separate modules. The more branches there are, the harder it is to read and test.
Creating a Module Registry
There are several ways to share modules within a team.
- Single Git repo with module collection: A
terraform-modulesrepo withvpc/,eks/,rds/subdirectories - Separate repos per module:
terraform-aws-vpc,terraform-aws-eks, etc. Version management is more independent - Private Terraform Registry: Terraform Cloud or self-hosted (S3 + HTTP server). Company modules become searchable by version like official modules
If the scale is small, the first approach (single terraform-modules repo) is a good starting point. As the company grows and the number of modules increases, evolving toward individual repos is natural.
In the next part, we’ll cover loops and conditionals. We’ll look at when to use count vs for_each, and how to cleanly organize complex configurations using dynamic blocks.


Loading comments...