Terraform · AWS · Networking

How to Create a Terraform VPC Module for AWS — Production-Ready Guide

Senior Cloud Architect, TheCloudQuest March 30, 2026 8 min read

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 documentation

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