Terraform Fundamentals - Modules
Learn how to use Terraform modules to reuse infrastructure code.
As Terraform projects grow in complexity, repetition becomes an unavoidable challenge. Multiple environments often require the same infrastructure elements—compute instances, storage buckets, networking components—deployed with only minor variations. Rather than duplicating code, Terraform offers a clean, maintainable solution: modules. Think of modules as the functions of Terraform—encapsulated, reusable blocks of configuration that can be referenced again and again across projects.
Back in Understanding the Anatomy of a Terraform Project, we laid out how to structure Terraform files (main.tf
, variables.tf
, outputs.tf
). In this post I explain how modules work, how to write one, and how to use existing modules both locally and from remote sources.
Follow my journey of 100 Days of Red Team on WhatsApp, Telegram or Discord.
What are modules in Terraform?
A module is a container for multiple Terraform configuration files grouped together to perform a specific task. Every Terraform configuration is already a module—the root module. But the real power lies in child modules that are explicitly called within the root configuration.
Modules promote reusability, readability, and standardization, especially when working in teams or across multiple environments. They're structured just like a regular Terraform project and typically include the following files:
main.tf
- Core resource definitionsvariables.tf
- Input variable declarationsoutputs.tf
- Exposed outputs for use elsewhere
Writing a reusable EC2 module
To demonstrate the working of a module let’s define a simplistic reusable EC2 module that provisions an instance based on variable inputs (a more elaborate example is available in 100 Days of Red Team GitHub repository). This module will live inside a modules
directory in the root Terraform project.
Directory structure:
project-root/
├── main.tf
├── variables.tf
├── outputs.tf
└── modules/
└── ec2/
├── main.tf
├── variables.tf
└── outputs.tf
modules/ec2/main.tf:
resource "aws_instance" "this" {
ami = var.ami
instance_type = var.instance_type
tags = {
Name = var.name
}
}
modules/ec2/variables.tf:
variable "ami" {
description = "AMI ID to use for the instance"
type = string
}
variable "instance_type" {
description = "EC2 instance type"
type = string
default = "t3.micro"
}
variable "name" {
description = "Name tag for the instance"
type = string
}
modules/ec2/outputs.tf:
output "instance_id" {
description = "ID of the EC2 instance"
value = aws_instance.this.id
}
This module is self-contained and can be reused multiple times across the root configuration or even in other projects.
Using a local module
Once the module is defined, calling it from the root module is straightforward:
main.tf:
module "web_server" {
source = "./modules/ec2"
ami = "ami-0abcd1234"
instance_type = "t3.micro"
name = "web-server"
}
output "web_instance_id" {
value = module.web_server.instance_id
}
Changing only the inputs allows this module to be used for different instances with consistent provisioning logic.
Inputs, outputs, and variables
Modules rely on input variables to accept dynamic values and output values to expose information. These make modules configurable and chainable across complex infrastructures.
Inputs - Declared using
variable
blocks insidevariables.tf
Outputs - Declared using
output
blocks, typically inoutputs.tf
These inputs and outputs are how the root module communicates with child modules, allowing flexibility without sacrificing structure.
Using remote modules from the Terraform registry
Terraform also supports pulling modules from remote sources such as GitHub or the official Terraform Registry. These can be version-controlled and reused across multiple projects.
Example using a module from the Terraform Registry:
module "ec2_instance" {
source = "terraform-aws-modules/ec2-instance/aws"
version = "5.0.0"
name = "example"
ami = "ami-0abcd1234"
instance_type = "t3.micro"
}
This references a public, community-maintained module that follows the same structure and conventions as a locally written module.
Are modules tied to a single provider?
Modules are designed to be reusable, but each one is typically built to work with a specific provider, like AWS or Azure, depending on the resources it creates. A module that uses aws_instance
obviously depends on the AWS provider. However, a single project can include modules for multiple providers—such as AWS for compute and Cloudflare for DNS—by defining each provider in the root configuration and explicitly assigning it to the relevant module if needed.
provider "aws" {
region = "us-east-1"
}
provider "cloudflare" {
email = var.cf_email
api_key = var.cf_api_key
}
module "aws_ec2" {
source = "./modules/ec2"
ami = "ami-0abcd1234"
name = "ec2-instance"
providers = {
aws = aws
}
}
module "cf_dns" {
source = "./modules/cloudflare-dns"
providers = {
cloudflare = cloudflare
}
}
To see this in action check this Terraform Hello World Module example (using a local module and using a remote module).
From a red team perspective, modules unlock a critical advantage: rapid, consistent deployment of ephemeral infrastructure. Instead of writing Terraform code from scratch every time a new engagement spins up, red team operators can build private modules for:
Redirectors (e.g., Nginx, iptables-based)
C2 hosts (e.g., hosting TeamServer, Covenant, Sliver)
DNS configurations
These modules can live in a private GitHub repository or internal registry, versioned and peer-reviewed. The result is not just automation—it’s stealthier operations, fewer mistakes, and reduced setup time.
Best practices for using Terraform modules
✅ Design modules to do one thing well—modularity improves clarity.
✅ Use variables for all configurable values.
✅ Document inputs and outputs clearly.
✅ Limit the number of outputs to only what’s necessary.
🚫 Avoid hardcoding provider logic inside modules—let the root module manage that.
✅ Version-lock modules from GitHub or the Registry using
ref
orversion
.
TL;DR
- Terraform modules help organize and reuse infrastructure code efficiently.
- Modules can be local (eg. stored on user's machine) or remote (eg. stored in a remote registry such as Terraform Registry)
- We can pass inputs to modules and retrieve outputs from modules to keep configurations clean.
Follow my journey of 100 Days of Red Team on WhatsApp, Telegram or Discord.