By Fatskills Exam Guides Team — the exam nerds behind 28,500+ quizzes and 2.1M practice questions across 500+ global exams.
(main.tf, variables.tf, outputs.tf)
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.
main.tf
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).
variables.tf
outputs.tf
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.
dev
prod
aws_vpc
aws_instance
cidr_block
instance_type
cidr_block = "10.0.0.0/16"
vpc_id
subnet_ids
security_group_id
source = "./modules/vpc"
source = "git::https://github.com/org/terraform-aws-vpc?ref=v1.2.0"
terraform apply
root → vpc → subnets → nacls
cidr_block = "10.0.0.0/160"
subnet_ids = aws_subnet.public[*].id
[*]
v1.0.0
main
>= 1.0.0
aws configure
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
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
# 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.
10.0.0.0/160
environment = "dev"
list(string)
# 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.
count
Environment
Name
# 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.
terraform output vpc_id
# 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)
sensitive = true
aws_kms_key
for_each
Owner
CostCenter
terraform fmt
terraform validate
Monitoring = "enabled"
terraform state list
validation
❌ main.tf (trick answer)
"How do you expose a VPC ID to other modules?"
output
❌ Hardcode it in main.tf.
"What’s the risk of not versioning modules?"
aws_subnet.public[0]
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:
depends_on
vpc_id = aws_vpc.this.id
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
public_ip
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.
validation { condition = can(cidrhost(var.cidr, 0)) }
value = aws_subnet.public[*].id
Join 4M+ learners. Unlock unlimited quizzes, wrong-answer tracking, flashcards + reminders, study guides, and 1-on-1 challenges.