Fatskills
Practice. Master. Repeat.
Study Guide: TECH **Terraform Module Design: Single Responsibility & Composable Best Practices**
Source: https://www.fatskills.com/cloud-application-developer/chapter/tech-terraform-module-design-single-responsibility-composable-best-practices

TECH **Terraform Module Design: Single Responsibility & Composable Best Practices**

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

⏱️ ~9 min read

Terraform Module Design: Single Responsibility & Composable Best Practices

(A Hyper-Practical, Zero-Fluff Guide for Engineers & Certification Prep)


1. What This Is & Why It Matters

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.

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.


2. Core Concepts & Components


1. Module

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.

2. Single Responsibility Principle (SRP)

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.

3. Input Variables

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.

4. Output Values

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.

5. Module Composition

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).

6. Module Registry

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.

7. Root Module

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.

8. Module Testing

Definition: Validating modules with tools like terratest or terraform validate.
Production insight: Test modules in isolation before composing them—catch bugs early.


3. Step-by-Step: Building a Composable VPC Module


Prerequisites

  • AWS account with admin IAM permissions
  • Terraform v1.0+ installed
  • AWS CLI configured (aws configure)

Goal:

Create a reusable VPC module with: - 1 VPC - 2 public subnets - 2 private subnets - NAT Gateway (for private subnets) - Internet Gateway (for public subnets)

Step 1: Create Module Structure

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

Step 2: Define Input Variables (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
}

Step 3: Define Resources (main.tf)

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 }

Step 4: Define Outputs (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
}

Step 5: Test the Module Locally

Create a test file (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"

Step 6: Publish to a Module Registry

  1. GitHub:
  2. Push to a repo (e.g., github.com/your-org/terraform-aws-vpc).
  3. Tag a release (v1.0.0).
  4. Terraform Registry:
  5. Go to Terraform Registry → Publish Module.
  6. Link your GitHub repo.

Now others can use your module:


module "vpc" {
  source  = "your-org/vpc/aws"
  version = "1.0.0"
  vpc_cidr = "10.2.0.0/16"
}


4. ? Production-Ready Best Practices


Security

  • Least privilege IAM: Modules should not hardcode IAM policies. Instead, pass IAM roles as inputs.
  • Secrets management: Never hardcode secrets in modules. Use aws_secretsmanager_secret or vault_generic_secret.
  • Network isolation: Modules should not expose internal resources (e.g., don’t output nat_gateway_id unless necessary).

Cost Optimization

  • Conditional resources: Use count or for_each to enable/disable expensive resources (e.g., NAT Gateway).
    hcl resource "aws_nat_gateway" "this" {
    count = var.enable_nat_gateway ? 1 : 0
    ...
    }
  • Spot instances: For non-critical workloads, use aws_spot_instance_request in modules.

Reliability & Maintainability

  • Version pinning: Always pin module versions to avoid breaking changes.
    hcl module "vpc" {
    source = "terraform-aws-modules/vpc/aws"
    version = "3.14.0" # ✅ Good
    # version = "~> 3.0" # ❌ Avoid (allows breaking changes) }
  • Tagging: Enforce consistent tags (e.g., Environment, Owner) across all resources.
    hcl tags = merge(var.tags, {
    Terraform = "true"
    Environment = var.environment })
  • Idempotency: Modules should produce the same result every time (no random names or timestamps).

Observability

  • CloudWatch alarms: Modules should optionally create alarms (e.g., aws_cloudwatch_metric_alarm for high CPU).
  • Logging: Enable VPC Flow Logs, S3 access logs, etc., by default.


5. ⚠️ Common Mistakes & Traps

Mistake Symptom Fix/Prevention
Hardcoding values (e.g., region = "us-east-1") Module can’t be reused in other regions. Use variables: region = var.region.
Exposing too many outputs Other modules depend on internal resources, making refactoring hard. Only expose what’s needed (e.g., vpc_id, not nat_gateway_id).
No version pinning terraform apply breaks after a module update. Always pin versions: version = "1.2.0".
Monolithic modules (e.g., VPC + EKS + RDS in one module) Hard to test, debug, and reuse. Split into smaller modules (VPC, EKS, RDS).
No input validation Users pass invalid CIDR blocks, causing errors. Add validation: validation { condition = can(cidrnetmask(var.cidr)) }.


6. ? Exam/Certification Focus


Typical Question Patterns

  1. "Which module design follows the Single Responsibility Principle?"
  2. Correct: A module that deploys only a VPC.
  3. Incorrect: A module that deploys a VPC + EKS + RDS.

  4. "How do you make a module reusable across environments?"

  5. Correct: Use input variables (e.g., var.environment).
  6. Incorrect: Hardcode values (e.g., environment = "prod").

  7. "What’s the best way to share a module across teams?"

  8. Correct: Publish to Terraform Registry or a private Git repo.
  9. Incorrect: Email a ZIP file.

Key Trap Distinctions

  • terraform_remote_state vs. module composition:
  • terraform_remote_state creates tight coupling (bad).
  • module blocks are loosely coupled (good).
  • count vs. for_each:
  • count is index-based (fragile if resources are deleted).
  • for_each is key-based (stable).

Scenario-Based Question

"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.


7. ? Hands-On Challenge

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.

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.


8. ? Rapid-Reference Crib Sheet

Concept Command/Example Notes
Module structure modules/vpc/{main.tf,variables.tf,outputs.tf} Standard layout.
Input variable variable "cidr_block" { type = string } Always define types.
Output value output "vpc_id" { value = aws_vpc.this.id } Expose only what’s needed.
Module composition module "vpc" { source = "./modules/vpc" } Use relative paths for local modules.
Version pinning source = "terraform-aws-modules/vpc/aws?ref=v3.14.0" ⚠️ Always pin versions.
Conditional resources count = var.enable_nat ? 1 : 0 Use count or for_each.
Tagging tags = merge(var.tags, { Terraform = "true" }) Enforce consistent tags.
Input validation validation { condition = can(cidrnetmask(var.cidr)) } Catch misconfigurations early.
Testing terraform validate Run before apply.
Publishing GitHub + Terraform Registry Share modules across teams.


9. ? Where to Go Next

  1. Terraform Module Registry – Browse official modules.
  2. Terraform Best Practices (HashiCorp) – Deep dive into module design.
  3. Terratest – Test your modules automatically.
  4. AWS Terraform Examples – Real-world module examples.

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! ?



ADVERTISEMENT