Chapter

Chapter 5: VPC Module - Complete Analysis


Overview

A deep dive into a production-ready Terraform VPC module for AWS: networking primitives, hardened defaults, and EKS-ready configuration. Learn how subnets, routing, NAT gateways, flow logs, and security boundaries are implemented so you can standardize your AWS network foundation across dev/stage/prod without drift.


5.1 Module Purpose & Design

Purpose

The VPC module provides a complete, production-ready networking foundation for AWS infrastructure. It creates:

The VPC module provides a complete, production-ready networking foundation for AWS infrastructure.

The Friction

  • Hardcoded CIDRs: Managing IP overlaps across 10+ environments is math hell.
  • Security Group Spaghetti: "Allow All" becomes the default when things break.
  • Manual Peering: Connecting VPCs manually leads to routing loops.

The R8way Solution

  • Dynamic Subnetting: cidrsubnets() calculates non-overlapping ranges automatically.
  • Deny-All Defaults: Security Groups start closed; strict allow-listing required.
  • TGW Integration: Transit Gateway support built-in for hub-and-spoke topology.

Design Philosophy

📦

1. Community Module Usage

Instead of reinventing the wheel, we leverage the battle-tested terraform-aws-modules/vpc/aws library.

  • Immediate access to patches
  • Proven handling of edge cases
  • Constant maintenance
🛡️

2. Secure by Default

We adhere to a Zero Trust architecture from day one. Security is not an afterthought.

  • Deny-All Defaults: Closed SGs
  • Audit Trails: Flow Logs on
  • Isolation: Private Subnets
☸️

3. EKS Optimized

Topology pre-configured for Kubernetes, eliminating common "Cluster Stuck" errors.

  • Auto-Discovery tags
  • Topology Aware routing
  • Scale Ready IP space
💰

4. Cost Awareness

Our defaults prevent "Bill Shock" by optimizing expensive networking components.

  • Smart NAT (Single GW)
  • Toggle-able HA
  • Right-Sizing Logs

5.2 Code Walkthrough

File:modules/vpc/main.tf

Let's analyze this file line by line:

Code
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "5.0.0"

Lines 1-3: Module Declaration

  • module "vpc" : Declares a module named "vpc" (local name)
  • source : Uses the community VPC module from Terraform Registry
  • version : Pins to version 5.0.0 for stability

Why Version 5.0.0?:

  • Support for latest AWS features
  • Security patches
  • Bug fixes
  • Backward compatibility
Code
  name = "${var.env}-vpc"
  cidr = var.vpc_cidr

Lines 5-6: Basic VPC Configuration

  • name : Dynamic VPC name using environment ( dev-vpc , prod-vpc )
  • cidr : VPC CIDR block from variable (e.g., 10.10.0.0/16 )

Naming Convention:

  • Clear identification in AWS Console
  • Easy filtering and searching
  • Consistent naming across environments
Code
  azs             = var.azs
  private_subnets = var.private_subnets
  public_subnets  = var.public_subnets

Lines 8-10: Subnet Configuration

  • azs : List of availability zones (e.g., ["us-east-1a", "us-east-1b"] )
  • private_subnets : CIDR blocks for private subnets (workloads)
  • public_subnets : CIDR blocks for public subnets (load balancers, NAT)

Subnet Design:

  • Private Subnets: No direct internet access, NAT Gateway required
  • Public Subnets: Direct internet access via Internet Gateway
  • Best Practice: Workloads in private, only gateways in public
Code
  enable_nat_gateway     = true
  single_nat_gateway     = var.single_nat_gateway
  one_nat_gateway_per_az = var.one_nat_gateway_per_az
  enable_vpn_gateway     = false

Lines 12-15: NAT Gateway Configuration

  • enable_nat_gateway = true : Always enable NAT (required for private subnet internet access)
  • single_nat_gateway : Single NAT for cost savings (default: true )
  • one_nat_gateway_per_az : One NAT per AZ for HA (default: false )
  • enable_vpn_gateway = false : VPN disabled (no site-to-site VPN)

Cost Consideration:

  • Single NAT: ~$32/month + data transfer
  • One per AZ: ~$96/month + data transfer (3 AZs)
  • Recommendation: Start with single NAT, add per-AZ if needed
Code
  # Flow Logs
  enable_flow_log                      = var.enable_flow_log
  create_flow_log_cloudwatch_log_group = var.enable_flow_log
  create_flow_log_cloudwatch_iam_role  = var.enable_flow_log
  flow_log_max_aggregation_interval    = 60
  flow_log_destination_type            = var.flow_log_destination_type

Lines 17-22: VPC Flow Logs

  • enable_flow_log : Master switch for flow logs (default: false )
  • create_flow_log_cloudwatch_log_group : Auto-create CloudWatch log group
  • create_flow_log_cloudwatch_iam_role : Auto-create IAM role for flow logs
  • flow_log_max_aggregation_interval = 60 : Aggregate logs every 60 seconds
  • flow_log_destination_type : CloudWatch Logs or S3 (default: CloudWatch)

Flow Logs Purpose:

  • Network traffic auditing
  • Security incident investigation
  • Compliance requirements (SOC 2, PCI-DSS)
  • Performance analysis

⚠️ Cost Note: Flow logs generate significant CloudWatch costs at scale. Consider S3 for long-term storage.

Code
  # Harden Default Security Group
  manage_default_security_group  = true
  default_security_group_ingress = [] # Deny all
  default_security_group_egress  = [] # Deny all

Lines 24-27: Security Group Hardening

  • manage_default_security_group = true : Take control of default SG
  • default_security_group_ingress = [] : No inbound rules (deny all)
  • default_security_group_egress = [] : No outbound rules (deny all)

Security Best Practice:

  • Default security groups often allow all traffic
  • This hardening prevents accidental exposure
  • Explicit security groups must be created for workloads
  • Follows "deny by default" security model
DANGER
Many resources use the default security group if not explicitly configured. Hardening prevents accidental exposure.
Code
  # Tags required for EKS
  public_subnet_tags = {
    "kubernetes.io/role/elb" = "1"
    "kubernetes.io/cluster/${var.cluster_name}" = "shared"
  }

  private_subnet_tags = {
    "kubernetes.io/role/internal-elb" = "1"
    "kubernetes.io/cluster/${var.cluster_name}" = "shared"
  }

Lines 29-38: EKS Subnet Tags

Public Subnet Tags:

  • kubernetes.io/role/elb = "1": Allow ELB to use these subnets
  • kubernetes.io/cluster/<name> = "shared": Mark for EKS cluster discovery

Private Subnet Tags:

  • kubernetes.io/role/internal-elb = "1": Allow internal ELB to use these subnets
  • kubernetes.io/cluster/<name> = "shared": Mark for EKS cluster discovery

Why These Tags?:

  • Kubernetes needs subnet tags to discover network topology
  • ELB controller uses tags to choose subnets
  • "shared" allows multiple clusters (if needed)

Tag Values:

  • "1" = Enable this role for the subnet
  • "shared" = Subnet can be shared across clusters (vs. "owned")
Code
  tags = {
    Environment = var.env
    Terraform   = "true"
  }
}

Lines 40-43: Default Tags

  • Environment = var.env : Tag with environment name
  • Terraform = "true" : Mark as Terraform-managed

Note:

  • Project = "Production-Grade-IaC"
  • ManagedBy = "Terragrunt"
  • Owner = "PlatformTeam"

5.3 Variables (variables.tf)

Complete Variable Reference

Code
variable "env" {
  description = "Environment name"
  type        = string
}

Purpose: Environment identifier (dev, staging, prod)

Usage: Used in resource naming and tagging

Example: env = "dev" → VPC named dev-vpc


Code
variable "vpc_cidr" {
  description = "CIDR block for VPC"
  type        = string
}

Purpose: VPC IP address range

Format: CIDR notation (e.g., 10.10.0.0/16)

Recommendations:

  • Use RFC 1918 private ranges: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
  • Common sizes:
  • /16 (65,536 IPs) - Large environments
  • /20 (4,096 IPs) - Medium environments
  • /24 (256 IPs) - Small environments

Example:

  • Dev: 10.10.0.0/16
  • Staging: 10.20.0.0/16
  • Prod: 10.30.0.0/16

Code
variable "azs" {
  description = "Availability Zones"
  type        = list(string)
}

Purpose: List of availability zones for subnet placement

Format: List of AZ names

Best Practices:

  • Use at least 2 AZs for high availability
  • Use 3 AZs for production workloads
  • Match AZs to instance requirements (some instance types limited to certain AZs)

Example: ["us-east-1a", "us-east-1b", "us-east-1c"]


Code
variable "private_subnets" {
  description = "Private Subnet CIDRs"
  type        = list(string)
}

Purpose: CIDR blocks for private subnets (workloads)

Format: List of CIDR blocks (one per AZ)

Requirements:

  • Must match length of azs variable
  • Must be within vpc_cidr range
  • Must not overlap with public_subnets

Example: ["10.10.1.0/24", "10.10.2.0/24"] (one per AZ)

Subnet Sizing Guide:

  • /24 = 256 IPs (sufficient for most workloads)
  • /22 = 1,024 IPs (large node groups)
  • /20 = 4,096 IPs (very large deployments)

Code
variable "public_subnets" {
  description = "Public Subnet CIDRs"
  type        = list(string)
}

Purpose: CIDR blocks for public subnets (load balancers, NAT)

Format: List of CIDR blocks (one per AZ)

Requirements:

  • Must match length of azs variable
  • Must be within vpc_cidr range
  • Must not overlap with private_subnets

Example: ["10.10.101.0/24", "10.10.102.0/24"]

Note: Public subnets typically need fewer IPs (only gateways), so /24 is usually sufficient.


Code
variable "cluster_name" {
  description = "EKS Cluster Name (for tagging)"
  type        = string
  default     = ""
}

Purpose: EKS cluster name for subnet tagging

Usage: Used in EKS subnet tags for cluster discovery

Default: Empty string (no EKS tags if not provided)

Example: cluster_name = "primary" → Tags include kubernetes.io/cluster/primary


Code
variable "enable_flow_log" {
  description = "Enable VPC Flow Logs"
  type        = bool
  default     = false
}

Purpose: Master switch for VPC Flow Logs

Default: false (disabled)

Recommendation: Enable for production environments

Cost: ~$0.50 per GB ingested (CloudWatch Logs)


Code
variable "flow_log_destination_type" {
  description = "Type of flow log destination (cloud-watch-logs or s3)"
  type        = string
  default     = "cloud-watch-logs"
}

Purpose: Where to send flow logs

Options:

  • "cloud-watch-logs": CloudWatch Logs (default, easier integration)
  • "s3": S3 bucket (cheaper for long-term storage)

Recommendation:

  • Use CloudWatch for real-time analysis
  • Use S3 for compliance archives

Code
variable "single_nat_gateway" {
  description = "Should be true if you want to provision a single shared NAT Gateway across all of your private networks"
  type        = bool
  default     = true
}

Purpose: Cost optimization option

Default: true (single NAT Gateway)

Use Cases:

  • true: Cost-sensitive, single NAT in first AZ
  • false: Requires one_nat_gateway_per_az = true for HA

Cost Impact:

  • Single NAT: ~$32/month
  • Per-AZ NAT: ~$96/month (3 AZs)

Code
variable "one_nat_gateway_per_az" {
  description = "Should be true if you want only one NAT Gateway per availability zone. Requires `var.azs` to be set, and the number of `public_subnets` created to be greater than or equal to the number of availability zones specified in `var.azs`."
  type        = bool
  default     = false
}

Purpose: High availability option

Default: false (disabled)

Use Cases:

  • true: High availability, one NAT per AZ
  • false: Single NAT (cost optimization)

Requirements:

  • Requires at least one public subnet per AZ
  • Requires enable_nat_gateway = true

5.4 Outputs (outputs.tf)

Complete Output Reference

Code
output "vpc_id" {
  description = "The ID of the VPC"
  value       = module.vpc.vpc_id
}

Purpose: VPC identifier for use by other modules

Format: vpc-xxxxxxxxx

Usage: Required by EKS and RDS modules

Example: dependency.vpc.outputs.vpc_id


Code
output "private_subnets" {
  description = "List of IDs of private subnets"
  value       = module.vpc.private_subnets
}

Purpose: Private subnet IDs for workload placement

Format: List of subnet IDs (e.g., ["subnet-xxx", "subnet-yyy"])

Usage: Used by EKS module for node groups, RDS module for DB subnets

Example: dependency.vpc.outputs.private_subnets


Code
output "public_subnets" {
  description = "List of IDs of public subnets"
  value       = module.vpc.public_subnets
}

Purpose: Public subnet IDs for load balancers

Format: List of subnet IDs

Usage: Used by EKS for public load balancers, NAT gateways

Example: dependency.vpc.outputs.public_subnets

Note: This output is provided for completeness but may not be used by EKS/RDS modules (which prefer private subnets).


5.5 Use Cases & Examples

Example 1: Basic Dev Environment

Configuration (values.yaml):

Code
vpc:
  cidr: "10.10.0.0/16"
  azs: ["us-east-1a", "us-east-1b"]
  private_subnets: ["10.10.1.0/24", "10.10.2.0/24"]
  public_subnets: ["10.10.101.0/24", "10.10.102.0/24"]
  enable_flow_log: false
  single_nat_gateway: true

Result:

  • VPC: 10.10.0.0/16 (65,536 IPs)
  • 2 AZs for cost savings
  • Private subnets: /24 each (256 IPs each)
  • Public subnets: /24 each (256 IPs each)
  • Single NAT Gateway (~$32/month)
  • Flow logs disabled (cost savings)

Example 2: Production Environment

Configuration (values.yaml):

Code
vpc:
  cidr: "10.30.0.0/16"
  azs: ["us-east-1a", "us-east-1b", "us-east-1c"]
  private_subnets: ["10.30.1.0/24", "10.30.2.0/24", "10.30.3.0/24"]
  public_subnets: ["10.30.101.0/24", "10.30.102.0/24", "10.30.103.0/24"]
  enable_flow_log: true
  flow_log_destination_type: "cloud-watch-logs"
  single_nat_gateway: false
  one_nat_gateway_per_az: true

Result:

  • VPC: 10.30.0.0/16 (production CIDR range)
  • 3 AZs for high availability
  • Private subnets: /24 each (256 IPs each, 3 subnets)
  • Public subnets: /24 each (256 IPs each, 3 subnets)
  • NAT Gateway per AZ (~$96/month, high availability)
  • Flow logs enabled (auditing and compliance)

Example 3: Large Scale Environment

Configuration (values.yaml):

Code
vpc:
  cidr: "10.40.0.0/16"
  azs: ["us-east-1a", "us-east-1b", "us-east-1c"]
  private_subnets: ["10.40.1.0/22", "10.40.5.0/22", "10.40.9.0/22"]
  public_subnets: ["10.40.13.0/24", "10.40.14.0/24", "10.40.15.0/24"]
  enable_flow_log: true
  flow_log_destination_type: "s3"
  single_nat_gateway: false
  one_nat_gateway_per_az: true

Result:

  • VPC: 10.40.0.0/16 (large CIDR range)
  • 3 AZs for high availability
  • Private subnets: /22 each (1,024 IPs each, for large node groups)
  • Public subnets: /24 each (256 IPs each, sufficient for gateways)
  • NAT Gateway per AZ (high availability)
  • Flow logs to S3 (cost-effective for long-term storage)

5.6 Customization Guide

Adding VPC Endpoints

To add VPC endpoints (for private access to AWS services), customize the module:

Option 1: Extend the Module

Create modules/vpc/endpoints.tf:

Code
resource "aws_vpc_endpoint" "s3" {
  vpc_id       = module.vpc.vpc_id
  service_name = "com.amazonaws.us-east-1.s3"
  route_table_ids = module.vpc.private_route_table_ids
}

Option 2: Add to Existing Module

Edit modules/vpc/main.tf:

Code
module "vpc" {
  # ... existing config ...

  enable_s3_endpoint = true
  # ... other endpoints ...
}


Custom Security Groups

The module hardens the default security group, but you can add custom security groups:

Code
resource "aws_security_group" "eks_nodes" {
  name        = "${var.env}-eks-nodes"
  description = "Security group for EKS nodes"
  vpc_id      = module.vpc.vpc_id

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    Name = "${var.env}-eks-nodes"
  }
}


Custom Route Tables

The module creates route tables automatically, but you can add custom ones:

Code
resource "aws_route_table" "custom" {
  vpc_id = module.vpc.vpc_id

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = module.vpc.internet_gateway_id
  }

  tags = {
    Name = "${var.env}-custom-rt"
  }
}


5.7 Common Patterns

Pattern 1: Multi-Region

Code
# live/us-east-1/dev-1/values.yaml
vpc:
  cidr: "10.10.0.0/16"
  azs: ["us-east-1a", "us-east-1b"]

# live/us-west-2/dev-1/values.yaml
vpc:
  cidr: "10.11.0.0/16"  # Different CIDR to avoid conflicts
  azs: ["us-west-2a", "us-west-2b"]


Pattern 2: Environment Isolation

Code
# Dev uses smaller CIDR ranges
vpc:
  cidr: "10.10.0.0/16"

# Prod uses separate CIDR ranges
vpc:
  cidr: "10.30.0.0/16"


5.8 Troubleshooting

Issue: Subnet Overlap

Error:

Code
Error: overlapping subnet CIDR blocks

Solution:

  • Ensure private_subnets and public_subnets don't overlap
  • Ensure all subnets are within vpc_cidr range
  • Verify CIDR calculations

Issue: Insufficient IP Addresses

Error:

Code
Error: subnet doesn't have enough IPs

Solution:

  • Increase subnet size (e.g., /24 → /22)
  • Use larger VPC CIDR block
  • Verify subnet sizes match workload requirements

Issue: EKS Tags Missing

Error:

Code
Error: EKS can't discover subnets

Solution:

  • Verify cluster_name variable is set
  • Check subnet tags include kubernetes.io/cluster/<name>
  • Ensure tags match cluster name

5.9 Chapter Summary

Executive Summary

  • Key Features
  • Production Ready: Pre-configured with public/private subnets, NAT Gateways, and Route Tables.
  • Security First: Hardened default security groups (Deny All) and optional VPC Flow Logs.
  • EKS Optimization: Automatic subnet tagging for Kubernetes Load Balancer discovery (kubernetes.io/role/elb).
  • Cost Flexibility: Toggle between Single NAT Gateway (Dev) and Multi-AZ NAT (Prod) via variables.
  • Integration
  • Inputs: Fully configurable via values.yaml (CIDR, AZs, Subnets).
  • Outputs: Exports vpc_id and subnet IDs for consumption by EKS and RDS modules.