Questions
Your team copies the same VPC code across 20 repositories. Design a reusable module architecture.
The Scenario
Your platform team maintains 20+ microservices, each with their own Terraform repository. Every repo has nearly identical VPC, security group, and networking code that was copy-pasted from a “template” repo:
repo-service-a/
└── terraform/
├── vpc.tf # 200 lines, copy-pasted
├── security.tf # 150 lines, copy-pasted
└── main.tf
repo-service-b/
└── terraform/
├── vpc.tf # Same 200 lines
├── security.tf # Same 150 lines, but with a bug fix that wasn't propagated
└── main.tf
# ... 18 more repos with drift between copies
A security vulnerability was found in the security group configuration. You need to patch 20 repos manually.
The Challenge
Design a reusable module architecture that eliminates duplication, enables centralized updates, and allows per-service customization. Consider versioning, testing, and adoption strategy.
A junior engineer might create a single monolithic module with dozens of variables to handle every use case, or skip versioning and have all consumers point to 'main' branch. This leads to modules that are hard to use, impossible to test, and risky to update since all consumers get changes immediately.
A senior engineer designs composable, single-purpose modules with clear interfaces, semantic versioning, and a private registry. Each module has a focused responsibility, sensible defaults, and is tested independently. Consumers pin to specific versions and upgrade deliberately.
Architecture Overview
terraform-modules/ # Central modules repository
├── modules/
│ ├── vpc/ # Single-purpose: VPC + subnets
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ ├── outputs.tf
│ │ ├── versions.tf
│ │ └── README.md
│ ├── security-group/ # Single-purpose: SG rules
│ ├── alb/ # Single-purpose: Load balancer
│ └── ecs-service/ # Composed module using others
├── examples/
│ ├── simple-vpc/
│ └── complete-vpc/
├── test/
│ └── vpc_test.go
└── .github/
└── workflows/
├── test.yml
└── release.ymlModule Design Principles
1. Single Responsibility:
# modules/vpc/main.tf
# This module ONLY creates VPC infrastructure
# It does NOT create security groups, instances, etc.
resource "aws_vpc" "this" {
cidr_block = var.cidr_block
enable_dns_hostnames = var.enable_dns_hostnames
enable_dns_support = var.enable_dns_support
tags = merge(var.tags, {
Name = var.name
})
}
resource "aws_subnet" "public" {
count = length(var.public_subnets)
vpc_id = aws_vpc.this.id
cidr_block = var.public_subnets[count.index]
availability_zone = var.azs[count.index]
map_public_ip_on_launch = true
tags = merge(var.tags, {
Name = "${var.name}-public-${var.azs[count.index]}"
Tier = "public"
})
}
resource "aws_subnet" "private" {
count = length(var.private_subnets)
vpc_id = aws_vpc.this.id
cidr_block = var.private_subnets[count.index]
availability_zone = var.azs[count.index]
tags = merge(var.tags, {
Name = "${var.name}-private-${var.azs[count.index]}"
Tier = "private"
})
}2. Sensible Defaults with Override Capability:
# modules/vpc/variables.tf
variable "name" {
description = "Name prefix for all resources"
type = string
}
variable "cidr_block" {
description = "VPC CIDR block"
type = string
default = "10.0.0.0/16"
}
variable "azs" {
description = "Availability zones"
type = list(string)
default = ["us-east-1a", "us-east-1b", "us-east-1c"]
}
variable "public_subnets" {
description = "Public subnet CIDR blocks"
type = list(string)
default = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
}
variable "private_subnets" {
description = "Private subnet CIDR blocks"
type = list(string)
default = ["10.0.11.0/24", "10.0.12.0/24", "10.0.13.0/24"]
}
variable "enable_nat_gateway" {
description = "Create NAT gateways for private subnets"
type = bool
default = true
}
variable "single_nat_gateway" {
description = "Use single NAT gateway (cost savings for non-prod)"
type = bool
default = false
}
variable "tags" {
description = "Tags to apply to all resources"
type = map(string)
default = {}
}3. Rich Outputs for Composition:
# modules/vpc/outputs.tf
output "vpc_id" {
description = "The ID of the VPC"
value = aws_vpc.this.id
}
output "vpc_cidr_block" {
description = "The CIDR block of the VPC"
value = aws_vpc.this.cidr_block
}
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_ips" {
description = "List of NAT Gateway public IPs"
value = aws_nat_gateway.this[*].public_ip
}
# Useful for downstream modules
output "azs" {
description = "Availability zones used"
value = var.azs
}Versioning Strategy
# In consumer's main.tf
module "vpc" {
source = "git::https://github.com/company/terraform-modules.git//modules/vpc?ref=v2.1.0"
# Or using private registry:
# source = "app.terraform.io/company/vpc/aws"
# version = "~> 2.1"
name = "service-a"
cidr_block = "10.1.0.0/16"
enable_nat_gateway = true
tags = {
Environment = "production"
Service = "service-a"
}
}Semantic Versioning Rules:
| Change Type | Version Bump | Example |
|---|---|---|
| Bug fix (backward compatible) | PATCH | 2.1.0 → 2.1.1 |
| New feature (backward compatible) | MINOR | 2.1.1 → 2.2.0 |
| Breaking change | MAJOR | 2.2.0 → 3.0.0 |
Composed Modules for Common Patterns
# modules/ecs-service/main.tf
# This module composes smaller modules for a complete ECS service
module "vpc" {
source = "../vpc"
name = var.service_name
enable_nat_gateway = true
single_nat_gateway = var.environment != "production"
tags = var.tags
}
module "alb" {
source = "../alb"
name = var.service_name
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.public_subnet_ids
certificate_arn = var.certificate_arn
tags = var.tags
}
module "ecs_cluster" {
source = "../ecs-cluster"
name = var.service_name
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnet_ids
tags = var.tags
}Private Module Registry
# Using Terraform Cloud Private Registry
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
module "vpc" {
source = "app.terraform.io/acme-corp/vpc/aws"
version = "2.1.0"
# ...
}Automated Release Pipeline
# .github/workflows/release.yml
name: Release Module
on:
push:
tags:
- 'v*'
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Validate version format
run: |
TAG=${GITHUB_REF#refs/tags/}
if [[ ! $TAG =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Invalid version format. Use semantic versioning (v1.2.3)"
exit 1
fi
- name: Run tests
run: |
cd test
go test -v -timeout 30m
- name: Create GitHub Release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref }}
release_name: Release ${{ github.ref }}
body: |
See CHANGELOG.md for details
draft: false
prerelease: false Migration Strategy for Existing Repos
# Step 1: Create module with current behavior
# Step 2: Have one service adopt it
# Step 3: Iterate based on feedback
# Step 4: Roll out to remaining services
# In each service repo:
# Before:
# └── terraform/
# ├── vpc.tf (200 lines)
# └── main.tf
# After:
# └── terraform/
# └── main.tf (20 lines using module)
Practice Question
Why should Terraform modules use semantic versioning and avoid having consumers point to 'main' branch?