100% Free Forever
AI-Powered Learning
Industry Expert Content
Certificates & Badges
Learn At Your Own Pace
Infrastructure as Code — Terraform & Ansible
60 minintermediate

Practice — Build a Reusable VPC Module with Public and Private Tiers

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

Analogy🏏Cricket
🏏 Think of it like cricket: You are designing the BCCI's standard cricket ground specification module — a parameterisable blueprint that can be instantiated for any venue in the country without redrawing it from scratch. The blueprint fixes the non-negotiables every certified ground shares: a boundary perimeter (the VPC and its CIDR), spectator zones with public access (public subnets with an Internet Gateway route), and restricted player-and-officials zones behind accreditation checks (private subnets routing through NAT). What varies per venue arrives as parameters: how many stands to build (var.azs and subnet counts), whether this is a full international stadium or a modest district ground (var.single_nat_gateway as the cost lever — one shared service corridor instead of one per stand), and the venue's name and signage (tags). Wankhede and a Ranchi district ground are instantiated from the same drawing with different inputs — and when the board later improves the blueprint (adds flow logs, tightens NACLs), every venue inherits the improvement on its next renovation (module version bump) instead of each ground hand-patching its own architecture. That is the entire economics of module authorship: design once, rigorously, then stamp out consistent grounds forever.

Prerequisites and Project Setup

bash
#!/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' | sort

Step 1 — Module: versions.tf and variables.tf

hcl
# 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

hcl
# 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

hcl
# 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" }
# }
Analogy🏏Cricket
🏏 Think of it like cricket: The outputs.tf you just wrote is the module's published scorecard — the only information the outside world is allowed to read. Inside the dressing room (the module), dozens of things exist: route tables, NAT gateways, association resources, locals doing CIDR arithmetic. The outputs block is the board's official announcement of results — vpc_id, public_subnet_ids, private_subnet_ids — the specific facts downstream consumers (the ALB configuration, the RDS configuration) are entitled to rely on. Everything not on the scorecard is dressing-room business: you can reorganise it freely between versions without breaking anyone, precisely because nobody outside was ever allowed to depend on it. This is why choosing outputs deserves as much thought as choosing variables — every output you publish is a commitment, like a stat the broadcaster starts displaying: once consumers build on it, withdrawing it is a breaking change requiring a major version, a formal announcement, and a migration period. Publish what callers genuinely need, and not one attribute more.

Step 4 — Test the Module

bash
#!/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.
Lesson 6 of 33
0% complete