By Fatskills Exam Guides Team — the exam nerds behind 28,500+ quizzes and 2.1M practice questions across 500+ global exams.
(A Hyper-Practical, Zero-Fluff Guide for Engineers & Certification Prep)
You’re a cloud engineer at a fast-growing startup. Your team just inherited a 5,000-line Terraform monolith that deploys everything from VPCs to Lambda functions in a single main.tf. Every time someone touches it, something breaks—CI/CD pipelines fail, state files corrupt, and rollbacks take hours. Worse, the same S3 bucket configuration is copy-pasted across 10 different environments, making updates a nightmare.
main.tf
This guide teaches you how to design Terraform modules with:✅ Single Responsibility – One module does one thing well (e.g., "deploy a VPC" vs. "deploy all of AWS").✅ Composability – Modules snap together like Lego blocks, so you can reuse them across projects without rewriting.
Why this matters in production:- Faster deployments: Change one module, not 50 files.- Fewer bugs: Isolated modules mean smaller blast radius when things go wrong.- Easier collaboration: Teams can work on different modules without stepping on each other.- Certification edge: HashiCorp exams (e.g., Terraform Associate) love testing module design.
Real-world scenario:You’re tasked with deploying a multi-region EKS cluster with: - A VPC in each region - IAM roles for the cluster - A private ECR repository for container images - CloudWatch alarms for monitoring
Instead of writing 500 lines of Terraform, you’ll compose 4-5 reusable modules (VPC, IAM, ECR, CloudWatch) and deploy them in minutes.
Definition: A self-contained Terraform configuration that encapsulates related resources (e.g., a VPC, an RDS instance).Production insight: Modules should be versioned (like v1.2.0) so teams can upgrade safely without breaking existing deployments.
v1.2.0
Definition: A module should do one thing and do it well (e.g., "deploy a VPC" vs. "deploy a VPC + EKS + RDS").Production insight: SRP makes modules easier to test, debug, and reuse. If your module has 20+ resources, it’s probably doing too much.
Definition: Parameters passed into a module (e.g., cidr_block, instance_type).Production insight: Always validate inputs (e.g., validation { condition = var.cidr_block == "10.0.0.0/16" }) to catch misconfigurations early.
cidr_block
instance_type
validation { condition = var.cidr_block == "10.0.0.0/16" }
Definition: Data exposed by a module for other modules to use (e.g., vpc_id, subnet_ids).Production insight: Outputs should be minimal and stable—don’t expose internal resources unless necessary.
vpc_id
subnet_ids
Definition: Combining multiple modules to build a larger system (e.g., vpc + eks + rds).Production insight: Use Terraform’s module block to reference other modules, not terraform_remote_state (which creates tight coupling).
vpc
eks
rds
module
terraform_remote_state
Definition: A central repository for sharing modules (e.g., Terraform Registry, GitHub, private S3).Production insight: Pin module versions (e.g., source = "terraform-aws-modules/vpc/aws?ref=v3.14.0") to avoid breaking changes.
source = "terraform-aws-modules/vpc/aws?ref=v3.14.0"
Definition: The top-level Terraform configuration that calls other modules.Production insight: The root module should be thin—just orchestrate modules, don’t define resources directly.
Definition: Validating modules with tools like terratest or terraform validate.Production insight: Test modules in isolation before composing them—catch bugs early.
terratest
terraform validate
aws configure
Create a reusable VPC module with: - 1 VPC - 2 public subnets - 2 private subnets - NAT Gateway (for private subnets) - Internet Gateway (for public subnets)
mkdir -p terraform-modules/vpc/{examples,test} cd terraform-modules/vpc touch main.tf variables.tf outputs.tf README.md
variables.tf
variable "vpc_cidr" { description = "CIDR block for the VPC" type = string default = "10.0.0.0/16" } variable "public_subnet_cidrs" { description = "CIDR blocks for public subnets" type = list(string) default = ["10.0.1.0/24", "10.0.2.0/24"] } variable "private_subnet_cidrs" { description = "CIDR blocks for private subnets" type = list(string) default = ["10.0.3.0/24", "10.0.4.0/24"] } variable "enable_nat_gateway" { description = "Enable NAT Gateway for private subnets" type = bool default = true }
resource "aws_vpc" "this" { cidr_block = var.vpc_cidr tags = { Name = "main-vpc" } } 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] tags = { Name = "public-subnet-${count.index}" } } 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 = { Name = "private-subnet-${count.index}" } } resource "aws_internet_gateway" "this" { vpc_id = aws_vpc.this.id } resource "aws_nat_gateway" "this" { count = var.enable_nat_gateway ? 1 : 0 subnet_id = aws_subnet.public[0].id allocation_id = aws_eip.nat[0].id } resource "aws_eip" "nat" { count = var.enable_nat_gateway ? 1 : 0 vpc = true }
outputs.tf
output "vpc_id" { description = "ID of the VPC" value = aws_vpc.this.id } output "public_subnet_ids" { description = "IDs of public subnets" value = aws_subnet.public[*].id } output "private_subnet_ids" { description = "IDs of private subnets" value = aws_subnet.private[*].id }
Create a test file (examples/simple-vpc/main.tf):
examples/simple-vpc/main.tf
module "vpc" { source = "../../" vpc_cidr = "10.1.0.0/16" public_subnet_cidrs = ["10.1.1.0/24", "10.1.2.0/24"] private_subnet_cidrs = ["10.1.3.0/24", "10.1.4.0/24"] } output "vpc_id" { value = module.vpc.vpc_id }
Run Terraform:
cd examples/simple-vpc terraform init terraform plan terraform apply
Expected output:
Apply complete! Resources: 8 added, 0 changed, 0 destroyed. Outputs: vpc_id = "vpc-12345678"
github.com/your-org/terraform-aws-vpc
v1.0.0
Now others can use your module:
module "vpc" { source = "your-org/vpc/aws" version = "1.0.0" vpc_cidr = "10.2.0.0/16" }
aws_secretsmanager_secret
vault_generic_secret
nat_gateway_id
count
for_each
hcl resource "aws_nat_gateway" "this" { count = var.enable_nat_gateway ? 1 : 0 ... }
aws_spot_instance_request
hcl module "vpc" { source = "terraform-aws-modules/vpc/aws" version = "3.14.0" # ✅ Good # version = "~> 3.0" # ❌ Avoid (allows breaking changes) }
Environment
Owner
hcl tags = merge(var.tags, { Terraform = "true" Environment = var.environment })
aws_cloudwatch_metric_alarm
region = "us-east-1"
region = var.region
terraform apply
version = "1.2.0"
validation { condition = can(cidrnetmask(var.cidr)) }
❌ Incorrect: A module that deploys a VPC + EKS + RDS.
"How do you make a module reusable across environments?"
var.environment
❌ Incorrect: Hardcode values (e.g., environment = "prod").
environment = "prod"
"What’s the best way to share a module across teams?"
"You need to deploy a VPC with public and private subnets in multiple AWS accounts. How do you structure your Terraform?"✅ Answer:- Create a reusable VPC module with inputs for cidr_block, subnet_cidrs, etc.- Call the module in each account’s root module with different variables.- Use Terraform workspaces or separate state files for each account.
subnet_cidrs
Challenge:Create a composable IAM module that: 1. Creates an IAM role with a trust policy for EC2.2. Attaches a managed policy (AmazonSSMManagedInstanceCore).3. Outputs the role ARN.
AmazonSSMManagedInstanceCore
Solution:
# modules/iam-role/main.tf resource "aws_iam_role" "this" { name = var.role_name assume_role_policy = jsonencode({ Version = "2012-10-17" Statement = [{ Action = "sts:AssumeRole" Effect = "Allow" Principal = { Service = "ec2.amazonaws.com" } }] }) } resource "aws_iam_role_policy_attachment" "ssm" { role = aws_iam_role.this.name policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore" } # modules/iam-role/variables.tf variable "role_name" { description = "Name of the IAM role" type = string } # modules/iam-role/outputs.tf output "role_arn" { value = aws_iam_role.this.arn }
Why it works:- Single responsibility: Only creates an IAM role.- Composable: Can be used in any Terraform project.- Reusable: Pass role_name as a variable.
role_name
modules/vpc/{main.tf,variables.tf,outputs.tf}
variable "cidr_block" { type = string }
output "vpc_id" { value = aws_vpc.this.id }
module "vpc" { source = "./modules/vpc" }
count = var.enable_nat ? 1 : 0
tags = merge(var.tags, { Terraform = "true" })
apply
Final Tip:
"A good Terraform module is like a Swiss Army knife—it does one thing well, fits in your pocket, and never breaks when you need it."
Now go refactor that monolith! ?
Join 4M+ learners. Unlock unlimited quizzes, wrong-answer tracking, flashcards + reminders, study guides, and 1-on-1 challenges.