LAB31_AWS_Reusable Terraform Module: VPC with Subnets and Route Tables

Reusable Terraform Module: VPC with Subnets and Route Tables

☁ AWS 🔥 Advanced Terraform Modules Module Interface outputs.tf variables.tf DRY

Scenario

Your Terraform configurations for dev, staging, and prod all contain nearly identical VPC code — 80 lines of subnets, route tables, and IGW that differ only by CIDR ranges and environment name. When you fixed a bug in the VPC code last month, you had to apply the same change three times.

Extract the VPC into a reusable local module with a clean published interface (input variables and outputs), then call it three times — once per environment — from a root config.

Your Objectives
  • Create a module at modules/vpc/ with variables.tf, main.tf, and outputs.tf. The module creates a VPC, public and private subnets, IGW, and route tables.
  • Define a clean module interface: required inputs (vpc_cidr, environment) and optional inputs with defaults (public_subnet_cidrs, private_subnet_cidrs, azs).
  • Add validation rules: CIDR format check, minimum 2 AZs, matching list lengths.
  • Expose outputs: vpc_id, public_subnet_ids, private_subnet_ids, igw_id.
  • In the root main.tf, call the module three times (dev, staging, prod) with different CIDRs. Use module.<name>.vpc_id to reference outputs.
  • Demonstrate terraform get, module sourcing from a local path, and how module outputs propagate to the root.

Additional Context

A module's variables.tf defines its input interface — the API contract callers must satisfy. Good modules have sensible defaults for optional inputs and validation rules so callers get clear error messages instead of cryptic AWS API errors. The outputs.tf defines the output interface — what parent modules or the root can reference.

Modules do not inherit the parent's provider configuration automatically. Providers are passed implicitly (if the module doesn't declare required_providers, it uses the parent's) or explicitly via providers map. For multi-region or multi-account modules, always pass providers explicitly.

modules/vpc/variables.tf

variable "vpc_cidr" {
  type        = string
  description = "CIDR block for the VPC."
  validation {
    condition     = can(cidrhost(var.vpc_cidr, 0))
    error_message = "vpc_cidr must be a valid IPv4 CIDR block."
  }
}

variable "environment" {
  type        = string
  description = "Environment name used in resource naming and tags."
  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "environment must be 'dev', 'staging', or 'prod'."
  }
}

variable "azs" {
  type        = list(string)
  description = "Availability zones to deploy subnets in. Must have at least 2."
  default     = ["us-east-1a", "us-east-1b"]
  validation {
    condition     = length(var.azs) >= 2
    error_message = "At least 2 Availability Zones are required."
  }
}

variable "public_subnet_cidrs" {
  type        = list(string)
  description = "CIDR blocks for public subnets — one per AZ."
  default     = ["10.0.1.0/24", "10.0.2.0/24"]
}

variable "private_subnet_cidrs" {
  type        = list(string)
  description = "CIDR blocks for private subnets — one per AZ."
  default     = ["10.0.11.0/24", "10.0.12.0/24"]
}

variable "tags" {
  type        = map(string)
  description = "Additional tags to merge with module defaults."
  default     = {}
}

modules/vpc/main.tf

# modules/vpc/main.tf

locals {
  common_tags = merge({ Environment = var.environment; ManagedBy = "terraform" }, var.tags)
}

resource "aws_vpc" "this" {
  cidr_block           = var.vpc_cidr
  enable_dns_support   = true
  enable_dns_hostnames = true
  tags = merge(local.common_tags, { Name = "vpc-${var.environment}" })
}

resource "aws_internet_gateway" "this" {
  vpc_id = aws_vpc.this.id
  tags   = merge(local.common_tags, { Name = "igw-${var.environment}" })
}

resource "aws_subnet" "public" {
  count                   = length(var.azs)
  vpc_id                  = aws_vpc.this.id
  cidr_block              = var.public_subnet_cidrs[count.index]
  availability_zone       = var.azs[count.index]
  map_public_ip_on_launch = true
  tags = merge(local.common_tags, { Name = "public-${var.azs[count.index]}-${var.environment}" })
}

resource "aws_subnet" "private" {
  count             = length(var.azs)
  vpc_id            = aws_vpc.this.id
  cidr_block        = var.private_subnet_cidrs[count.index]
  availability_zone = var.azs[count.index]
  tags = merge(local.common_tags, { Name = "private-${var.azs[count.index]}-${var.environment}" })
}

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 = "public-rt-${var.environment}" })
}

resource "aws_route_table_association" "public" {
  count          = length(var.azs)
  subnet_id      = aws_subnet.public[count.index].id
  route_table_id = aws_route_table.public.id
}

resource "aws_route_table" "private" {
  vpc_id = aws_vpc.this.id
  tags   = merge(local.common_tags, { Name = "private-rt-${var.environment}" })
}

modules/vpc/outputs.tf

output "vpc_id"             { value = aws_vpc.this.id }
output "vpc_cidr"           { value = aws_vpc.this.cidr_block }
output "public_subnet_ids"  { value = aws_subnet.public[*].id }
output "private_subnet_ids" { value = aws_subnet.private[*].id }
output "igw_id"             { value = aws_internet_gateway.this.id }
output "public_route_table_id"  { value = aws_route_table.public.id }
output "private_route_table_id" { value = aws_route_table.private.id }

Root main.tf — calling the module 3 times

# root/main.tf — calls the VPC module once per environment

provider "aws" { region = "us-east-1" }

module "vpc_dev" {
  source = "./modules/vpc"

  environment          = "dev"
  vpc_cidr             = "10.10.0.0/16"
  azs                  = ["us-east-1a", "us-east-1b"]
  public_subnet_cidrs  = ["10.10.1.0/24", "10.10.2.0/24"]
  private_subnet_cidrs = ["10.10.11.0/24", "10.10.12.0/24"]
}

module "vpc_staging" {
  source = "./modules/vpc"

  environment          = "staging"
  vpc_cidr             = "10.20.0.0/16"
  azs                  = ["us-east-1a", "us-east-1b"]
  public_subnet_cidrs  = ["10.20.1.0/24", "10.20.2.0/24"]
  private_subnet_cidrs = ["10.20.11.0/24", "10.20.12.0/24"]
}

module "vpc_prod" {
  source = "./modules/vpc"

  environment          = "prod"
  vpc_cidr             = "10.30.0.0/16"
  azs                  = ["us-east-1a", "us-east-1b", "us-east-1c"]
  public_subnet_cidrs  = ["10.30.1.0/24", "10.30.2.0/24", "10.30.3.0/24"]
  private_subnet_cidrs = ["10.30.11.0/24", "10.30.12.0/24", "10.30.13.0/24"]

  tags = { CostCenter = "prod-001" }
}

Root outputs.tf

# Access module outputs using module..
output "dev_vpc_id"     { value = module.vpc_dev.vpc_id }
output "staging_vpc_id" { value = module.vpc_staging.vpc_id }
output "prod_vpc_id"    { value = module.vpc_prod.vpc_id }

output "prod_public_subnets"  { value = module.vpc_prod.public_subnet_ids }
output "prod_private_subnets" { value = module.vpc_prod.private_subnet_ids }

Workflow Commands

# terraform get downloads and links local modules before init.
# With local sources, init handles this automatically.
terraform init      # downloads/links all modules
terraform plan      # shows 3 VPCs worth of resources
terraform apply

# View module-scoped state addresses
terraform state list | grep module

# Access any module output
terraform output prod_vpc_id
terraform output -json prod_public_subnets

terraform destroy
✓ Expected Output After Apply
Apply complete! Resources: 24 added, 0 changed, 0 destroyed.

Outputs:

dev_vpc_id     = "vpc-0aaa..."
prod_private_subnets = ["subnet-0bbb...", "subnet-0ccc...", "subnet-0ddd..."]
prod_public_subnets  = ["subnet-0eee...", "subnet-0fff...", "subnet-0ggg..."]
prod_vpc_id    = "vpc-0hhh..."
staging_vpc_id = "vpc-0iii..."