What You'll Build
You will design and author a production-grade, reusable VPC module that can be consumed across multiple environments with different parameters — without any code changes. The module will provision a VPC with configurable public and private subnet tiers across a configurable number of AZs, optional NAT gateways (with the cost-saving single-NAT option for development environments), VPC Flow Logs enabled by default, S3 and DynamoDB gateway endpoints to reduce NAT costs, and a thoughtfully designed output set that exposes everything callers might plausibly need. You will then instantiate the module twice in a root configuration to demonstrate how the same module code deploys differently-sized VPCs for production (3 AZs, per-AZ NAT) and development (2 AZs, single NAT).
Prerequisites and Project Setup
#!/bin/bash
# Create module project structure
mkdir -p ~/cricket_vpc_module/{modules/vpc,environments/{production,development}}
cd ~/cricket_vpc_module
git init
# Module files
touch modules/vpc/{main.tf,variables.tf,outputs.tf,versions.tf,README.md}
# Root environment files
touch environments/production/{main.tf,production.tfvars,versions.tf}
touch environments/development/{main.tf,development.tfvars,versions.tf}
cat > .gitignore << 'EOF'
.terraform/
*.tfstate
*.tfstate.backup
*.tfplan
.terraform.lock.hcl
EOF
git add .
git commit -m 'chore: initialise VPC module project structure'
echo 'Structure:'
find . -name '*.tf' -o -name '*.tfvars' | sortStep 1 — Module: versions.tf and variables.tf
# modules/vpc/versions.tf
terraform {
required_version = ">= 1.6.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0, < 6.0"
}
}
}
# modules/vpc/variables.tf
variable "name" {
description = "Name prefix for all VPC resources. Use project-environment format (e.g. cricket-production)."
type = string
validation {
condition = can(regex("^[a-z0-9-]{3,32}$", var.name))
error_message = "name must be 3-32 lowercase alphanumeric characters and hyphens."
}
}
variable "vpc_cidr" {
description = "IPv4 CIDR block for the VPC. Must be /16 to /20. Choose a block that does not overlap on-premises ranges."
type = string
default = "10.0.0.0/16"
validation {
condition = can(cidrhost(var.vpc_cidr, 0))
error_message = "vpc_cidr must be a valid IPv4 CIDR block (e.g. 10.0.0.0/16)."
}
validation {
condition = (
tonumber(split("/", var.vpc_cidr)[1]) >= 16 &&
tonumber(split("/", var.vpc_cidr)[1]) <= 20
)
error_message = "vpc_cidr prefix must be between /16 and /20 for adequate subnet allocation."
}
}
variable "azs" {
description = "List of Availability Zone names for subnet distribution. Provide 2 or 3."
type = list(string)
validation {
condition = length(var.azs) >= 2 && length(var.azs) <= 3
error_message = "Provide 2 or 3 Availability Zone names."
}
}
variable "enable_nat_gateway" {
description = "Whether to create NAT Gateways for private subnet internet egress."
type = bool
default = true
}
variable "single_nat_gateway" {
description = "Use one shared NAT Gateway instead of one per AZ. Reduces cost and HA — suitable for dev only."
type = bool
default = false
validation {
condition = !(var.single_nat_gateway == false && var.enable_nat_gateway == false)
error_message = "single_nat_gateway is only relevant when enable_nat_gateway is true."
}
}
variable "enable_flow_logs" {
description = "Enable VPC Flow Logs. Recommended for production; disable in dev to reduce CloudWatch costs."
type = bool
default = true
}
variable "flow_log_retention_days" {
description = "CloudWatch Logs retention for VPC Flow Logs."
type = number
default = 14
validation {
condition = contains([1, 3, 5, 7, 14, 30, 60, 90, 180, 365], var.flow_log_retention_days)
error_message = "flow_log_retention_days must be a valid CloudWatch Logs retention value."
}
}
variable "tags" {
description = "Additional tags to merge with module-generated tags."
type = map(string)
default = {}
}Step 2 — Module: main.tf
# modules/vpc/main.tf
locals {
az_count = length(var.azs)
nat_count = var.enable_nat_gateway ? (var.single_nat_gateway ? 1 : local.az_count) : 0
# Subnet CIDR allocation:
# Public: /24 subnets (256 IPs each) — for ALB, NAT GW
# Private: /20 subnets (4096 IPs each) — for EC2, EKS pods
# Data: /24 subnets — for RDS, ElastiCache
public_cidrs = [for i in range(local.az_count) : cidrsubnet(var.vpc_cidr, 8, i)]
private_cidrs = [for i in range(local.az_count) : cidrsubnet(var.vpc_cidr, 4, i + 1)]
data_cidrs = [for i in range(local.az_count) : cidrsubnet(var.vpc_cidr, 8, i + 30)]
common_tags = merge(var.tags, {
ManagedBy = "terraform"
Module = "cricket-vpc"
})
}
# ── VPC ────────────────────────────────────────────────────────────────────────
resource "aws_vpc" "this" {
cidr_block = var.vpc_cidr
enable_dns_hostnames = true
enable_dns_support = true
tags = merge(local.common_tags, { Name = var.name })
}
resource "aws_internet_gateway" "this" {
vpc_id = aws_vpc.this.id
tags = merge(local.common_tags, { Name = "${var.name}-igw" })
}
# ── Subnets ────────────────────────────────────────────────────────────────────
resource "aws_subnet" "public" {
count = local.az_count
vpc_id = aws_vpc.this.id
cidr_block = local.public_cidrs[count.index]
availability_zone = var.azs[count.index]
tags = merge(local.common_tags, {
Name = "${var.name}-public-${var.azs[count.index]}"
Tier = "public"
"kubernetes.io/role/elb" = "1" # Required for ALB discovery by EKS
})
}
resource "aws_subnet" "private" {
count = local.az_count
vpc_id = aws_vpc.this.id
cidr_block = local.private_cidrs[count.index]
availability_zone = var.azs[count.index]
tags = merge(local.common_tags, {
Name = "${var.name}-private-${var.azs[count.index]}"
Tier = "private"
"kubernetes.io/role/internal-elb" = "1" # For internal ALBs in EKS
})
}
resource "aws_subnet" "data" {
count = local.az_count
vpc_id = aws_vpc.this.id
cidr_block = local.data_cidrs[count.index]
availability_zone = var.azs[count.index]
tags = merge(local.common_tags, {
Name = "${var.name}-data-${var.azs[count.index]}"
Tier = "data"
})
}
# ── NAT Gateways ───────────────────────────────────────────────────────────────
resource "aws_eip" "nat" {
count = local.nat_count
domain = "vpc"
depends_on = [aws_internet_gateway.this]
tags = merge(local.common_tags, { Name = "${var.name}-nat-eip-${count.index}" })
}
resource "aws_nat_gateway" "this" {
count = local.nat_count
allocation_id = aws_eip.nat[count.index].id
subnet_id = aws_subnet.public[var.single_nat_gateway ? 0 : count.index].id
tags = merge(local.common_tags, { Name = "${var.name}-nat-${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(local.common_tags, { Name = "${var.name}-public-rt" })
}
resource "aws_route_table" "private" {
count = local.az_count
vpc_id = aws_vpc.this.id
dynamic "route" {
for_each = local.nat_count > 0 ? [1] : []
content {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.this[var.single_nat_gateway ? 0 : count.index].id
}
}
tags = merge(local.common_tags, { Name = "${var.name}-private-rt-${count.index}" })
}
resource "aws_route_table_association" "public" {
count = local.az_count
subnet_id = aws_subnet.public[count.index].id
route_table_id = aws_route_table.public.id
}
resource "aws_route_table_association" "private" {
count = local.az_count
subnet_id = aws_subnet.private[count.index].id
route_table_id = aws_route_table.private[count.index].id
}
# ── VPC Endpoints — eliminate NAT Gateway costs for S3 and DynamoDB ───────────
resource "aws_vpc_endpoint" "s3" {
vpc_id = aws_vpc.this.id
service_name = "com.amazonaws.${data.aws_region.current.name}.s3"
route_table_ids = aws_route_table.private[*].id
tags = merge(local.common_tags, { Name = "${var.name}-s3-endpoint" })
}
resource "aws_vpc_endpoint" "dynamodb" {
vpc_id = aws_vpc.this.id
service_name = "com.amazonaws.${data.aws_region.current.name}.dynamodb"
route_table_ids = aws_route_table.private[*].id
tags = merge(local.common_tags, { Name = "${var.name}-dynamodb-endpoint" })
}
data "aws_region" "current" {}
# ── VPC Flow Logs — conditional ───────────────────────────────────────────────
resource "aws_cloudwatch_log_group" "flow_logs" {
count = var.enable_flow_logs ? 1 : 0
name = "/aws/vpc/flow-logs/${var.name}"
retention_in_days = var.flow_log_retention_days
tags = merge(local.common_tags, { Name = "${var.name}-flow-logs" })
}
resource "aws_iam_role" "flow_logs" {
count = var.enable_flow_logs ? 1 : 0
name = "${var.name}-vpc-flow-logs"
assume_role_policy = jsonencode({
Statement = [{ Effect = "Allow", Principal = { Service = "vpc-flow-logs.amazonaws.com" }, Action = "sts:AssumeRole" }]
})
tags = local.common_tags
}
resource "aws_iam_role_policy" "flow_logs" {
count = var.enable_flow_logs ? 1 : 0
role = aws_iam_role.flow_logs[0].id
policy = jsonencode({
Statement = [{ Effect = "Allow", Action = ["logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents", "logs:DescribeLogGroups", "logs:DescribeLogStreams"], Resource = "*" }]
})
}
resource "aws_flow_log" "this" {
count = var.enable_flow_logs ? 1 : 0
vpc_id = aws_vpc.this.id
traffic_type = "ALL"
iam_role_arn = aws_iam_role.flow_logs[0].arn
log_destination = aws_cloudwatch_log_group.flow_logs[0].arn
tags = merge(local.common_tags, { Name = "${var.name}-flow-log" })
}Step 3 — Module: outputs.tf and Root Configurations
# modules/vpc/outputs.tf
output "vpc_id" { value = aws_vpc.this.id; description = "VPC ID." }
output "vpc_cidr" { value = aws_vpc.this.cidr_block; description = "VPC CIDR block." }
output "public_subnet_ids" { value = aws_subnet.public[*].id; description = "Public subnet IDs." }
output "private_subnet_ids" { value = aws_subnet.private[*].id; description = "Private subnet IDs." }
output "data_subnet_ids" { value = aws_subnet.data[*].id; description = "Data subnet IDs." }
output "nat_gateway_ids" { value = aws_nat_gateway.this[*].id; description = "NAT Gateway IDs." }
output "nat_gateway_ips" { value = aws_eip.nat[*].public_ip; description = "NAT Gateway Elastic IPs." }
output "s3_endpoint_id" { value = aws_vpc_endpoint.s3.id; description = "S3 gateway endpoint ID." }
output "dynamodb_endpoint_id" { value = aws_vpc_endpoint.dynamodb.id; description = "DynamoDB gateway endpoint ID." }
output "flow_logs_enabled" { value = var.enable_flow_logs; description = "Whether flow logs are enabled." }
# Structured output for passing all networking context to modules in one variable
output "networking" {
description = "Complete networking context — pass this single output to application modules."
value = {
vpc_id = aws_vpc.this.id
vpc_cidr = aws_vpc.this.cidr_block
public_subnet_ids = aws_subnet.public[*].id
private_subnet_ids = aws_subnet.private[*].id
data_subnet_ids = aws_subnet.data[*].id
}
}
# ── environments/production/main.tf ──────────────────────────────────────────
terraform {
required_version = ">= 1.6.0"
required_providers {
aws = { source = "hashicorp/aws", version = "~> 5.0" }
}
}
provider "aws" { region = var.aws_region }
module "vpc" {
source = "../../modules/vpc"
name = "cricket-production"
vpc_cidr = var.vpc_cidr
azs = var.azs
enable_nat_gateway = true
single_nat_gateway = false # Production: HA — one NAT per AZ
enable_flow_logs = true
flow_log_retention_days = 30
tags = { Environment = "production", CostCenter = "CC-001" }
}
variable "aws_region" { default = "ap-south-1" }
variable "vpc_cidr" { default = "10.0.0.0/16" }
variable "azs" { default = ["ap-south-1a", "ap-south-1b", "ap-south-1c"] }
output "vpc_id" { value = module.vpc.vpc_id }
output "private_subnet_ids" { value = module.vpc.private_subnet_ids }
output "nat_ips" { value = module.vpc.nat_gateway_ips }
# ── environments/development/main.tf ─────────────────────────────────────────
# (identical structure, different module arguments)
# module "vpc" {
# source = "../../modules/vpc"
# name = "cricket-development"
# vpc_cidr = "10.2.0.0/16" # Non-overlapping CIDR
# azs = ["ap-south-1a", "ap-south-1b"] # Only 2 AZs
# enable_nat_gateway = true
# single_nat_gateway = true # Dev: save ~$64/month
# enable_flow_logs = false # Dev: save CloudWatch costs
# tags = { Environment = "development", CostCenter = "CC-DEV" }
# }Step 4 — Test the Module
#!/bin/bash
cd ~/cricket_vpc_module
echo '=== Test production environment ==='
cd environments/production
terraform init
terraform validate
terraform plan -var-file=production.tfvars -out=prod.tfplan 2>&1 | tail -20
# Review: 3 public subnets, 3 private subnets, 3 NAT GWs, flow logs enabled
# Count resources that would be created
terraform show -json prod.tfplan 2>/dev/null | \
jq '[.resource_changes[] | select(.change.actions[]=="create")] | length'
echo
echo '=== Test development environment ==='
cd ../../environments/development
terraform init
terraform validate
terraform plan -out=dev.tfplan 2>&1 | tail -10
# Review: 2 public subnets, 2 private subnets, 1 NAT GW (single), flow logs disabled
echo
echo '=== Validate module interface ==='
cd ../../modules/vpc
# Test validation: invalid name
cd /tmp && mkdir -p test_invalid && cd test_invalid
cat > main.tf << 'EOF'
terraform { required_providers { aws = { source = "hashicorp/aws" } } }
provider "aws" { region = "ap-south-1" }
module "vpc" {
source = "~/cricket_vpc_module/modules/vpc"
name = "INVALID NAME WITH SPACES" # Should fail validation
vpc_cidr = "10.0.0.0/16"
azs = ["ap-south-1a", "ap-south-1b"]
}
EOF
terraform init -no-color 2>/dev/null
terraform validate 2>&1 | grep -A2 'Error\|error_message'
# Expected: validation error for invalid name format
rm -rf /tmp/test_invalid
echo 'Module testing complete'Extension Challenge: Extend the VPC module with three production enhancements: (1) Add an 'enable_ssm_endpoints' variable that, when true, creates Interface VPC endpoints for SSM, EC2Messages and SSMMessages — enabling SSM Session Manager from instances in private subnets that have no internet access (a stronger security posture than allowing outbound HTTPS); (2) Add a 'secondary_cidr_blocks' variable (list of strings) that attaches additional CIDR blocks to the VPC using 'aws_vpc_ipv4_cidr_block_association' — necessary for EKS clusters that need more pod IPs than the primary CIDR provides; (3) Write a README using terraform-docs output and add at least two usage examples: minimal (required variables only) and complete (all variables specified).
- The nat_count local ('var.single_nat_gateway ? 1 : local.az_count') centralises the three-way NAT configuration logic in one place — resources reference nat_count rather than each containing their own conditional logic.
- Include Kubernetes subnet tags ('kubernetes.io/role/elb' and 'kubernetes.io/role/internal-elb') in VPC modules even if you are not using EKS today — adding tags later requires a subnet update that may cause brief traffic interruption for EKS clusters.
- The structured 'networking' output that returns all networking context as a single object allows callers to pass one variable rather than four or five separate subnet ID lists — a significant ergonomic improvement for module callers.
- VPC gateway endpoints for S3 and DynamoDB are free and eliminate NAT Gateway data processing charges for S3 and DynamoDB traffic — always include them in VPC modules and add them to all existing private route tables.
- Test module validation by deliberately providing invalid inputs and confirming the validation error message is clear and actionable — modules with good validation messages are self-documenting.
- Use non-overlapping CIDR blocks across environments (10.0.0.0/16 for production, 10.2.0.0/16 for development) even if VPC peering is not planned today — retroactively implementing peering with overlapping CIDRs is not possible without recreating at least one VPC.