Fatskills
Practice. Master. Repeat.
Study Guide: TECH **Terraform Module Structure: A Hyper-Practical Guide**
Source: https://www.fatskills.com/cloud-application-developer/chapter/tech-terraform-module-structure-a-hyper-practical-guide

TECH **Terraform Module Structure: A Hyper-Practical Guide**

By Fatskills Exam Guides Team — the exam nerds behind 28,500+ quizzes and 2.1M practice questions across 500+ global exams.

⏱️ ~10 min read

Terraform Module Structure: A Hyper-Practical Guide

(main.tf, variables.tf, outputs.tf)


1. What This Is & Why It Matters

You’re a cloud engineer tasked with deploying a multi-environment AWS infrastructure (dev, staging, prod) for a microservices app. Your team is growing, and the current Terraform codebase is a monolithic main.tf with hardcoded values, no reusability, and zero consistency. Every time someone deploys a new VPC, they copy-paste the same 200 lines of code, tweak a few values, and hope for the best. Disaster is inevitable.

This is where Terraform modules come in. A module is a self-contained, reusable unit of infrastructure—like a Lego block for your cloud. By structuring your code into main.tf, variables.tf, and outputs.tf, you: ✅ Eliminate copy-paste errors (one source of truth).
Enforce consistency (same VPC, same security groups, same tags).
Simplify maintenance (change one file, update all environments).
Enable team collaboration (modules can be versioned and shared).

Real-world scenario:
You inherit a Terraform repo where every environment (dev, prod) has its own main.tf with duplicated code. A security audit finds that prod is missing a critical NACL rule that exists in dev. Fixing it means manually updating dozens of files. With modules, you’d update one file, and all environments inherit the change.


2. Core Concepts & Components


1. main.tf

  • Definition: The "brain" of your module—contains the actual resource definitions (e.g., aws_vpc, aws_instance).
  • Production insight: If main.tf is cluttered with hardcoded values, you’re violating the DRY (Don’t Repeat Yourself) principle. This leads to configuration drift (dev ≠ staging ≠ prod).

2. variables.tf

  • Definition: Declares inputs to your module (e.g., cidr_block, instance_type). Think of it like function parameters in code.
  • Production insight: Without variables, you’re hardcoding values, making your module useless for other environments. Example: A VPC module with cidr_block = "10.0.0.0/16" can’t be reused for a second VPC.

3. outputs.tf

  • Definition: Exposes values from your module (e.g., vpc_id, subnet_ids). These can be used by other modules or the root configuration.
  • Production insight: If you don’t output critical values (like security_group_id), other teams can’t reference them, forcing them to hardcode ARNs or IDs—a security and reliability risk.

4. Module Sources

  • Definition: Where Terraform pulls the module from (local path, GitHub, Terraform Registry, S3, etc.).
  • Production insight: Using source = "./modules/vpc" is fine for local development, but in production, pin to a Git tag or version (e.g., source = "git::https://github.com/org/terraform-aws-vpc?ref=v1.2.0") to avoid breaking changes.

5. Root Module vs. Child Module

  • Definition:
  • Root module: The top-level Terraform configuration (where you run terraform apply).
  • Child module: A reusable module called by the root (or another child).
  • Production insight: If you nest modules too deeply (e.g., root → vpc → subnets → nacls), debugging becomes a nightmare. Keep it shallow (1-2 levels max).

6. Variable Validation

  • Definition: Rules to enforce valid inputs (e.g., cidr_block must be a valid CIDR range).
  • Production insight: Without validation, a typo like cidr_block = "10.0.0.0/160" (invalid) will fail at runtime, wasting time and money.

7. Output Dependencies

  • Definition: Outputs can reference other outputs (e.g., subnet_ids = aws_subnet.public[*].id).
  • Production insight: If you don’t use splat expressions ([*]) for dynamic resources (like subnets), your outputs will break when scaling.

8. Module Versioning

  • Definition: Tagging modules (e.g., v1.0.0) to ensure reproducibility.
  • Production insight: If you don’t version modules, a breaking change in main can destroy your production environment during an update.


3. Step-by-Step: Build a Reusable VPC Module


Prerequisites

  • AWS account with admin IAM permissions.
  • Terraform installed (>= 1.0.0).
  • AWS CLI configured (aws configure).

Goal

Create a reusable VPC module with: - 1 VPC - 2 public subnets (spread across AZs) - 2 private subnets - Internet Gateway (IGW) - NAT Gateway (for private subnets) - Route tables

Step 1: Set Up the Module Structure

mkdir -p terraform-modules/vpc/{examples,modules/vpc}
cd terraform-modules/vpc
touch modules/vpc/{main.tf,variables.tf,outputs.tf}
touch examples/basic/main.tf

Step 2: Define variables.tf (Inputs)

# modules/vpc/variables.tf
variable "vpc_cidr" {
  description = "CIDR block for the VPC"
  type        = string
  validation {
condition = can(cidrhost(var.vpc_cidr, 0))
error_message = "Must be a valid CIDR block (e.g., 10.0.0.0/16)." } } variable "public_subnet_cidrs" { description = "List of CIDR blocks for public subnets" type = list(string) validation {
condition = length(var.public_subnet_cidrs) >= 2
error_message = "At least 2 public subnets are required." } } variable "private_subnet_cidrs" { description = "List of CIDR blocks for private subnets" type = list(string) validation {
condition = length(var.private_subnet_cidrs) >= 2
error_message = "At least 2 private subnets are required." } } variable "environment" { description = "Environment name (e.g., dev, prod)" type = string default = "dev" } variable "tags" { description = "Additional tags for resources" type = map(string) default = {} }

Why this matters:
- Validation prevents invalid CIDRs (e.g., 10.0.0.0/160).
- Default values (environment = "dev") make the module flexible.
- Type constraints (list(string)) catch errors early.


Step 3: Define main.tf (Resources)

# modules/vpc/main.tf
resource "aws_vpc" "this" {
  cidr_block           = var.vpc_cidr
  enable_dns_support   = true
  enable_dns_hostnames = true
  tags = merge(
var.tags,
{
Name = "${var.environment}-vpc"
Environment = var.environment
} ) } resource "aws_subnet" "public" { count = length(var.public_subnet_cidrs) vpc_id = aws_vpc.this.id cidr_block = var.public_subnet_cidrs[count.index] availability_zone = data.aws_availability_zones.available.names[count.index] map_public_ip_on_launch = true tags = merge(
var.tags,
{
Name = "${var.environment}-public-subnet-${count.index}"
Environment = var.environment
} ) } resource "aws_subnet" "private" { count = length(var.private_subnet_cidrs) vpc_id = aws_vpc.this.id cidr_block = var.private_subnet_cidrs[count.index] availability_zone = data.aws_availability_zones.available.names[count.index] tags = merge(
var.tags,
{
Name = "${var.environment}-private-subnet-${count.index}"
Environment = var.environment
} ) } # Internet Gateway resource "aws_internet_gateway" "this" { vpc_id = aws_vpc.this.id tags = merge(
var.tags,
{
Name = "${var.environment}-igw"
} ) } # NAT Gateway (one per AZ) resource "aws_eip" "nat" { count = length(var.public_subnet_cidrs) vpc = true tags = merge(
var.tags,
{
Name = "${var.environment}-nat-eip-${count.index}"
} ) } resource "aws_nat_gateway" "this" { count = length(var.public_subnet_cidrs) allocation_id = aws_eip.nat[count.index].id subnet_id = aws_subnet.public[count.index].id tags = merge(
var.tags,
{
Name = "${var.environment}-nat-gw-${count.index}"
} ) } # Route Tables resource "aws_route_table" "public" { vpc_id = aws_vpc.this.id route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.this.id } tags = merge(
var.tags,
{
Name = "${var.environment}-public-rt"
} ) } resource "aws_route_table_association" "public" { count = length(var.public_subnet_cidrs) subnet_id = aws_subnet.public[count.index].id route_table_id = aws_route_table.public.id } resource "aws_route_table" "private" { count = length(var.private_subnet_cidrs) vpc_id = aws_vpc.this.id route {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.this[count.index].id } tags = merge(
var.tags,
{
Name = "${var.environment}-private-rt-${count.index}"
} ) } resource "aws_route_table_association" "private" { count = length(var.private_subnet_cidrs) subnet_id = aws_subnet.private[count.index].id route_table_id = aws_route_table.private[count.index].id } # Data source for AZs data "aws_availability_zones" "available" { state = "available" }

Why this matters:
- Dynamic subnets (count) allow scaling without code changes.
- NAT Gateway per AZ ensures high availability.
- Tagging (Environment, Name) is critical for cost tracking and security.


Step 4: Define outputs.tf (Exports)

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

output "public_subnet_ids" {
  description = "List of public subnet IDs"
  value       = aws_subnet.public[*].id
}

output "private_subnet_ids" {
  description = "List of private subnet IDs"
  value       = aws_subnet.private[*].id
}

output "nat_gateway_ids" {
  description = "List of NAT Gateway IDs"
  value       = aws_nat_gateway.this[*].id
}

output "internet_gateway_id" {
  description = "The ID of the Internet Gateway"
  value       = aws_internet_gateway.this.id
}

Why this matters:
- Other modules (e.g., EKS, RDS) need these IDs to attach resources.
- Debugging is easier when you can terraform output vpc_id.


Step 5: Test the Module (Example Usage)

# examples/basic/main.tf
provider "aws" {
  region = "us-east-1"
}

module "vpc" {
  source               = "../../modules/vpc"
  vpc_cidr             = "10.0.0.0/16"
  public_subnet_cidrs  = ["10.0.1.0/24", "10.0.2.0/24"]
  private_subnet_cidrs = ["10.0.3.0/24", "10.0.4.0/24"]
  environment          = "dev"
  tags = {
Owner = "dev-team"
ManagedBy = "Terraform"
CostCenter = "12345" } } output "vpc_id" { value = module.vpc.vpc_id }

Run it:


cd examples/basic
terraform init
terraform plan
terraform apply -auto-approve

Verify:


aws ec2 describe-vpcs --vpc-ids $(terraform output -raw vpc_id)


4. ? Production-Ready Best Practices


Security

  • Never hardcode secrets (use sensitive = true for variables like DB passwords).
  • Least privilege IAM roles for modules (e.g., a VPC module shouldn’t need S3 permissions).
  • Encrypt outputs (e.g., aws_kms_key for sensitive data).

Cost Optimization

  • Use count or for_each for dynamic resources (e.g., subnets) to avoid over-provisioning.
  • Tag everything (Environment, Owner, CostCenter) for cost allocation reports.
  • Use spot instances for non-critical workloads (via module variables).

Reliability & Maintainability

  • Pin module versions (e.g., source = "git::https://github.com/org/terraform-aws-vpc?ref=v1.2.0").
  • Use terraform fmt and terraform validate in CI/CD.
  • Document inputs/outputs in variables.tf and outputs.tf (Terraform will auto-generate docs).

Observability

  • Output critical IDs (e.g., vpc_id, security_group_id) for monitoring tools.
  • Tag resources for CloudWatch alarms (e.g., Monitoring = "enabled").
  • Use terraform state list to audit resources.


5. ⚠️ Common Mistakes & Traps

Mistake Symptom Fix/Prevention
Hardcoding values in main.tf Can’t reuse module for different environments. Always use variables.tf.
Forgetting outputs.tf Other modules can’t reference resources (e.g., vpc_id). Output all critical IDs.
Not validating variables Invalid CIDRs or missing subnets cause runtime failures. Use validation blocks.
Deeply nested modules Debugging becomes a nightmare. Keep nesting to 1-2 levels max.
No version pinning A breaking change in main destroys production. Pin to Git tags or Terraform Registry versions.


6. ? Exam/Certification Focus


Typical Question Patterns

  1. "Which file declares module inputs?"
  2. variables.tf
  3. main.tf (trick answer)

  4. "How do you expose a VPC ID to other modules?"

  5. ✅ Define an output in outputs.tf.
  6. ❌ Hardcode it in main.tf.

  7. "What’s the risk of not versioning modules?"

  8. ✅ A breaking change in main can destroy production.
  9. ❌ "It’s fine, just use source = "./modules/vpc"" (wrong).

Key Trap Distinctions

  • count vs. for_each:
  • count is index-based (e.g., aws_subnet.public[0]).
  • for_each is key-based (e.g., aws_subnet.public["us-east-1a"]).
  • Exam trap: If you delete an item in the middle of a count list, Terraform destroys and recreates all subsequent resources.

  • depends_on in modules:

  • Explicit dependencies slow down Terraform.
  • Better: Use implicit dependencies (e.g., vpc_id = aws_vpc.this.id).


7. ? Hands-On Challenge

Challenge:
Create a reusable EC2 module with: - 1 EC2 instance - A security group allowing SSH (port 22) and HTTP (port 80) - Output the public_ip and instance_id

Solution:


# modules/ec2/main.tf
resource "aws_instance" "this" {
  ami           = var.ami_id
  instance_type = var.instance_type
  subnet_id     = var.subnet_id
  vpc_security_group_ids = [aws_security_group.this.id]
  tags = merge(var.tags, { Name = "${var.environment}-ec2" })
}

resource "aws_security_group" "this" {
  name        = "${var.environment}-ec2-sg"
  description = "Allow SSH and HTTP"
  vpc_id      = var.vpc_id

  ingress {
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"] } ingress {
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"] } egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"] } } # modules/ec2/variables.tf variable "ami_id" { type = string } variable "instance_type" { type = string } variable "subnet_id" { type = string } variable "vpc_id" { type = string } variable "environment" { type = string } variable "tags" { type = map(string) } # modules/ec2/outputs.tf output "instance_id" { value = aws_instance.this.id } output "public_ip" { value = aws_instance.this.public_ip }

Why it works:
- Reusable (works for any VPC/subnet).
- Secure (only allows SSH/HTTP).
- Outputs allow other modules to reference the instance.


8. ? Rapid-Reference Crib Sheet

Concept Command/Example Notes
Module structure main.tf, variables.tf, outputs.tf Required files.
Variable validation validation { condition = can(cidrhost(var.cidr, 0)) } Prevents invalid CIDRs.
Output a list value = aws_subnet.public[*].id Splat expression.
Module source (local) source = "./modules/vpc" For development.
Module source (Git) `source = "git::https://github.com/org


ADVERTISEMENT