Creating a reusable Terraform VPC module for AWS is one of the most valuable investments a DevOps team in India can make. This guide walks you through building a production-grade module from scratch — the same patterns used in enterprise deployments across Bangalore, Hyderabad, Pune, and Mumbai.
Why use a Terraform module for AWS VPC?
Every AWS project needs a VPC. Writing raw VPC Terraform code in every project leads to inconsistency, security misconfigurations, and duplicated effort. A well-built module lets your entire team deploy identical, compliant VPC configurations across all environments — dev, staging, and production — with a single source of truth.
- Consistent subnet sizing and CIDR allocation across all environments
- Built-in tagging strategy for cost allocation and compliance
- Enforced security group defaults and network ACLs
- Remote state locking prevents concurrent deployment conflicts
- Reusable across multiple AWS accounts and regions
Module structure
A production Terraform VPC module has this directory structure:
modules/
└── vpc/
├── main.tf # VPC, subnets, IGW, NAT, route tables
├── variables.tf # All input variables
├── outputs.tf # VPC ID, subnet IDs, etc.
├── versions.tf # Provider + Terraform version constraints
└── README.md # Input/output documentationStep 1 — Define your variables
Start with variables.tf. Good variable design is the difference between a flexible module and a rigid one-trick file.
# variables.tf
variable "vpc_name" { type = string }
variable "vpc_cidr" { type = string default = "10.0.0.0/16" }
variable "azs" { type = list(string) }
variable "private_subnets" { type = list(string) }
variable "public_subnets" { type = list(string) }
variable "enable_nat_gateway" { type = bool default = true }
variable "single_nat_gateway" { type = bool default = false }
variable "enable_dns_hostnames" { type = bool default = true }
variable "tags" { type = map(string) default = {} }Step 2 — Build main.tf
The core of the module. This creates the VPC, public and private subnets, internet gateway, NAT gateways, and route tables:
# main.tf
resource "aws_vpc" "this" {
cidr_block = var.vpc_cidr
enable_dns_support = true
enable_dns_hostnames = var.enable_dns_hostnames
tags = merge(var.tags, { Name = var.vpc_name })
}
resource "aws_internet_gateway" "this" {
vpc_id = aws_vpc.this.id
tags = merge(var.tags, { Name = "${var.vpc_name}-igw" })
}
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.vpc_name}-public-${count.index + 1}"
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.vpc_name}-private-${count.index + 1}"
Tier = "private"
})
}
resource "aws_eip" "nat" {
count = var.enable_nat_gateway ? (var.single_nat_gateway ? 1 : length(var.azs)) : 0
domain = "vpc"
}
resource "aws_nat_gateway" "this" {
count = var.enable_nat_gateway ? (var.single_nat_gateway ? 1 : length(var.azs)) : 0
allocation_id = aws_eip.nat[count.index].id
subnet_id = aws_subnet.public[count.index].id
tags = merge(var.tags, { Name = "${var.vpc_name}-nat-${count.index + 1}" })
depends_on = [aws_internet_gateway.this]
}
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(var.tags, { Name = "${var.vpc_name}-public-rt" })
}
resource "aws_route_table_association" "public" {
count = length(aws_subnet.public)
subnet_id = aws_subnet.public[count.index].id
route_table_id = aws_route_table.public.id
}
resource "aws_route_table" "private" {
count = var.single_nat_gateway ? 1 : length(var.azs)
vpc_id = aws_vpc.this.id
dynamic "route" {
for_each = var.enable_nat_gateway ? [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(var.tags, { Name = "${var.vpc_name}-private-rt-${count.index + 1}" })
}
resource "aws_route_table_association" "private" {
count = length(aws_subnet.private)
subnet_id = aws_subnet.private[count.index].id
route_table_id = aws_route_table.private[var.single_nat_gateway ? 0 : count.index].id
}Step 3 — Outputs
# outputs.tf
output "vpc_id" { value = aws_vpc.this.id }
output "vpc_cidr_block" { 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 "nat_gateway_ids" { value = aws_nat_gateway.this[*].id }Step 4 — Call the module from your root config
# root/main.tf
module "vpc" {
source = "./modules/vpc"
vpc_name = "prod-vpc"
vpc_cidr = "10.0.0.0/16"
azs = ["ap-south-1a", "ap-south-1b", "ap-south-1c"]
public_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
private_subnets = ["10.0.10.0/24", "10.0.11.0/24", "10.0.12.0/24"]
enable_nat_gateway = true
single_nat_gateway = false # true for dev/staging to save cost
tags = {
Environment = "production"
Team = "platform"
CostCenter = "infra"
ManagedBy = "terraform"
}
}Production checklist before you apply
- Remote state configured in S3 with DynamoDB locking
- State file encrypted with KMS
- Terraform version pinned in versions.tf (>= 1.6)
- AWS provider version pinned (~> 5.0)
- VPC Flow Logs enabled for security and audit compliance
- All subnets tagged with Environment, Team, and CostCenter
- CIDR blocks planned for future expansion (don't use /24 for large deployments)
- NAT gateway count matches number of AZs (for HA — not single_nat_gateway)
Common mistakes Indian DevOps teams make with Terraform VPC modules
✗ Hardcoding region in the module
✓ Always pass the AWS region as a variable or use a provider alias. Your module should work in ap-south-1 (Mumbai), us-east-1, and any other region without changes.
✗ Using a single NAT gateway in production
✓ Single NAT gateway saves ~₹3,000/month but creates a single point of failure. Use one NAT gateway per AZ in production. Use single_nat_gateway = true only in dev/staging.
✗ Not planning CIDR ranges ahead
✓ Plan for VPC peering, Direct Connect, and future subnets. Using 10.0.0.0/16 gives you 65,536 IPs. Never use /24 at the VPC level for enterprise workloads.
✗ Skipping VPC Flow Logs
✓ Flow logs are required for SOC 2, ISO 27001, and most enterprise compliance frameworks. Enable them from day one — retrofitting is painful.
Need a custom Terraform VPC module?
Get a production-ready, enterprise-grade Terraform module tailored to your exact requirements — multi-region, compliance-ready, with remote state and full documentation. Delivered in 24–48 hours by a Senior Cloud Architect.