Skip to main content

Terraform Ep 9: Architecting Reusable, Production-Grade Modules

Rachmat Hidayat
Author
Rachmat Hidayat
Learn & sharing insights on TypeScript, Go, Kubernetes, DevOps, DevSecOps, SRE, Platform Engineering, AI/ML Engineering, and MLOps.
terraform - This article is part of a series.
Part 9: This Article
If you are copy-pasting the same VPC and Security Group configurations across 5 different projects, you are accumulating massive technical debt. In software engineering, when we have repetitive logic, we extract it into a Function or a Class. In Platform Engineering, we extract it into a Terraform Module.

1. The Core Concept: What is a Module?
#

A Terraform Module is simply a standard folder containing one or more .tf files. That is it. There is no special syntax or file extension required to create a module.

In fact, every time you run Terraform, you are already using a module. The folder where you run terraform apply is technically called the Root Module.

When the Root Module calls another folder containing Terraform code, that called folder is referred to as a Child Module.

Why use Modules?
#

  1. Reusability: Write the complex logic for an EKS Cluster once, and allow 10 different application teams to deploy it simply by providing a few variables.
  2. Encapsulation: Hide complex, messy resources (like IAM Roles, Security Groups, and Route Tables) behind a clean, simple interface.
  3. Versioning: By storing modules in separate Git repositories, you can tag them (e.g., v1.0.0). Teams can upgrade to newer versions of the infrastructure safely.

2. Practice: Building a VPC Child Module
#

Let’s build a highly reusable AWS VPC module. We want this module to create a VPC, an Internet Gateway, and a Subnet, but we want the consumer (the Root Module) to decide the CIDR blocks and the Environment Name.

Step 2.1: The Directory Structure
#

Create a folder structure like this:

terraform-projects/
├── modules/
│   └── vpc-module/           # <--- This is our Child Module
│       ├── main.tf
│       ├── variables.tf
│       └── outputs.tf
└── my-app-prod/              # <--- This is our Root Module
    └── main.tf
mkdir -p terraform-projects/modules/vpc-module
mkdir -p terraform-projects/my-app-prod

Step 2.2: Writing the Child Module (The Logic)
#

Navigate into the vpc-module folder.

1. Define the Inputs (variables.tf) These are the arguments our module will accept.

# modules/vpc-module/variables.tf
variable "vpc_cidr" {
  description = "The CIDR block for the entire VPC"
  type        = string
}

variable "subnet_cidr" {
  description = "The CIDR block for the public subnet"
  type        = string
}

variable "env_name" {
  description = "The name of the environment (e.g., prod, staging)"
  type        = string
}

2. Define the Infrastructure (main.tf) Notice how we DO NOT hardcode anything. We strictly use the variables.

# modules/vpc-module/main.tf
resource "aws_vpc" "this" {
  cidr_block = var.vpc_cidr
  tags = {
    Name = "${var.env_name}-vpc"
  }
}

resource "aws_internet_gateway" "this" {
  vpc_id = aws_vpc.this.id
  tags = {
    Name = "${var.env_name}-igw"
  }
}

resource "aws_subnet" "public" {
  vpc_id     = aws_vpc.this.id
  cidr_block = var.subnet_cidr
  tags = {
    Name = "${var.env_name}-public-subnet"
  }
}

3. Define the Outputs (outputs.tf) The Root Module will likely need to know the IDs of the resources we just created so it can deploy EC2 instances into them.

# modules/vpc-module/outputs.tf
output "vpc_id" {
  description = "The ID of the created VPC"
  value       = aws_vpc.this.id
}

output "public_subnet_id" {
  description = "The ID of the created Public Subnet"
  value       = aws_subnet.public.id
}

Congratulations! You have just authored a production-grade Terraform Module.


3. Practice: Consuming the Module
#

Now, navigate to the my-app-prod folder (The Root Module). We are going to call our newly created Child Module and pass in the required arguments.

cd ../../my-app-prod

Create a main.tf file:

# my-app-prod/main.tf

provider "aws" {
  region = "us-east-1"
}

# 1. Calling the Module
module "production_network" {
  # The 'source' argument tells Terraform where to find the module.
  # This can be a local path, a Git URL, or the Terraform Registry.
  source = "../modules/vpc-module"
  
  # Injecting values into the module's variables
  env_name    = "prod-app"
  vpc_cidr    = "10.10.0.0/16"
  subnet_cidr = "10.10.1.0/24"
}

# 2. Using the Module's Outputs
resource "aws_instance" "web" {
  ami           = "ami-123456"
  instance_type = "t3.micro"
  
  # We extract the Subnet ID returned by the module!
  subnet_id = module.production_network.public_subnet_id
}

The Magic of the module Block
#

By writing just 6 lines of code in the Root Module, we successfully deployed a VPC, an Internet Gateway, and a Subnet, perfectly tagged and parameterized.

If another team wants to deploy a Staging environment, they simply create a my-app-staging folder, call the exact same module, and pass env_name = "staging" and a different CIDR block. Absolute reusability.


4. Initializing and Executing Modules
#

When you use a module, you MUST run terraform init before running terraform plan.

terraform init

Expected Terminal Output:

Initializing modules...
- production_network in ../modules/vpc-module

Initializing the backend...
Initializing provider plugins...

Terraform detects the module block, navigates to the relative path ../modules/vpc-module, and copies the module’s code into your hidden .terraform/modules directory.

Run terraform plan to verify the execution. You will see that Terraform intends to create the VPC, the Subnet, the IGW, and the EC2 instance, resolving all dependencies perfectly.


5. Remote Modules and Versioning (Git)
#

Referencing modules via local relative paths (source = "../modules/") is fine for monorepos, but in large enterprises, you want to store your modules in dedicated Git repositories.

Terraform natively supports downloading modules directly from GitHub, GitLab, or Bitbucket.

module "production_network" {
  # Terraform will git clone this repository automatically!
  # Notice the ?ref=v1.2.0 tag. This guarantees IMMUTABILITY.
  source = "git::https://github.com/my-company/terraform-aws-vpc-module.git?ref=v1.2.0"
  
  env_name    = "prod-app"
  vpc_cidr    = "10.10.0.0/16"
  subnet_cidr = "10.10.1.0/24"
}

By pinning the ref=v1.2.0, you ensure that even if the Platform Team introduces a breaking change in v2.0.0 of the module, your production Root Module remains completely unaffected until you manually update the ref string.


Troubleshooting & Common Errors
#

  1. Module not installed

    • Root Cause: You added a new module block or changed the source argument, but you attempted to run terraform plan without initializing.
    • Solution: Always run terraform init whenever you modify module sources.
  2. Missing required argument

    • Root Cause: The Child Module’s variables.tf declares a variable without a default value, but you failed to pass that variable in the Root Module’s module block.
    • Solution: Review the Child Module’s variables and ensure you pass all required inputs.
  3. Reference to undeclared output value

    • Root Cause: You tried to use module.my_module.my_id, but the Child Module’s outputs.tf file does not actually output anything named my_id.
    • Solution: A Root Module cannot magically reach inside a Child Module’s state. The Child Module MUST explicitly declare an output block for the Root Module to consume it.

Conclusion & Next Steps
#

You have successfully graduated from writing flat scripts to architecting enterprise-grade modules. You can now build self-service infrastructure catalogs for your entire company.

However, as your team grows, you will face a critical problem: What happens if two engineers run terraform apply at the exact same time on the exact same Root Module? The local .tfstate file will become desynchronized, leading to catastrophic infrastructure corruption.

In the final episode of Tier 2, Episode 10: Remote State Backends and Concurrency Locking, we will solve this by migrating our state file to AWS S3 and DynamoDB.

terraform - This article is part of a series.
Part 9: This Article