Your First Reusable Module: Building a Production-Ready AWS VPC with Terraform

Atif FarrukhAtif Farrukh
9 min read

You’ve seen it. I’ve seen it. I’ve walked into companies where the network topology looks less like a deliberate architecture and more like a digital swamp. Multiple, disconnected VPCs, created manually through the AWS console, each a unique "snowflake" environment. Security groups are a spaghetti mess of 0.0.0.0/0 rules, and nobody can remember why a specific subnet was created three years ago.

This isn't just messy; it's a direct threat to the business. Launching a new service grinds to a halt because provisioning a secure network takes weeks of planning and approvals. A failed audit sends teams scrambling for weeks to fix glaring security holes that were baked in from day one. This is the moment where engineering velocity dies, drowned in the process and technical debt of its own making.

The cure isn't another meeting or a new wiki page. The cure is to codify your network blueprint. Today, I'm giving you my blueprint for a reusable, production-ready AWS VPC built with Terraform. This isn't a toy example. This is the foundational module I use to ensure every environment—from development to production—is consistent, secure, and built for scale.


Architecture Context

Before we write a single line of HCL, let's establish the "why." We aren't just building a VPC; we are creating a network factory. The goal is to produce identical, secure, and well-structured network foundations on demand. A Terraform module is the perfect tool for this because it enforces consistency and abstracts away the complexity. Any engineer should be able to spin up a new, compliant network stack in minutes, not weeks.

Our architecture is a standard, battle-tested three-tier design that provides strong security zoning:

  1. Public Subnets: This is the DMZ. Only resources that must be directly exposed to the internet live here, like load balancers and bastion hosts. These subnets have a route to the Internet Gateway.

  2. Private Subnets: This is where your applications and compute resources (like Kubernetes nodes or EC2 instances) run. They are isolated from direct internet access. To reach the internet for things like pulling packages or calling external APIs, they route through a NAT Gateway that resides in the public subnet.

  3. Data Subnets: Maximum security. This tier is for your databases (RDS), caches (ElastiCache), and other stateful services. These subnets are the most restricted; they typically have no internet access at all and can only be reached by resources in the private subnets.

Here’s what that looks like visually:

+-------------------------------------------------------------------------+
| AWS Region                                                              |
|                                                                         |
|  +-------------------------------------------------------------------+  |
|  | VPC (e.g., 10.0.0.0/16)                                           |  |
|  |                                                                   |  |
|  |   +-----------------------+         +-------------------------+   |  |
|  |   |    Availability Zone A  |         |   Availability Zone B |   |  |
|  |   +-----------------------+         +-------------------------+   |  |
|  |   |                       |         |                         |   |  |
|  |   | [Public Subnet] <-----> IGW <-----> [Public Subnet]       |   |  |
|  |   |   - NAT Gateway A     |         |   - NAT Gateway B       |   |  |
|  |   |       ^               |         |           ^             |   |  |
|  |   |       |               |         |           |             |   |  |
|  |   | [Private Subnet]      |         | [Private Subnet]        |   |  |
|  |   |   - App Servers       |         |   - App Servers         |   |  |
|  |   |       ^               |         |           ^             |   |  |
|  |   |       |               |         |           |             |   |  |
|  |   | [Data Subnet]         |         | [Data Subnet]           |   |  |
|  |   |   - Database          |         |   - Database            |   |  |
|  |   |                       |         |                         |   |  |
|  |   +-----------------------+         +-------------------------+   |  |
|  |                                                                   |  |
|  +-------------------------------------------------------------------+  |
|                                                                         |
+-------------------------------------------------------------------------+

Implementation Details

This is where the rubber meets the road. We'll structure our module to be clean, reusable, and driven by variables.

Module Structure

A good module is self-contained. Here's a clean layout:

modules/
└── vpc/
    ├── main.tf      # Core resources (VPC, subnets, gateways)
    ├── variables.tf # Input variables for customization
    └── outputs.tf     # Outputs for other modules to use

variables.tf

These are the knobs and dials for our module. Notice we're defining defaults for non-critical things but forcing the user to provide essential context like the project name and CIDR block.

variable "aws_region" {
  description = "The AWS region to deploy resources in."
  type        = string
  default     = "us-east-1"
}

variable "project_name" {
  description = "The name of the project, used for tagging."
  type        = string
}

variable "vpc_cidr_block" {
  description = "The CIDR block for the VPC."
  type        = string
}

variable "availability_zones" {
  description = "A list of availability zones to create subnets in."
  type        = list(string)
}

main.tf

This is the core logic. We create the VPC, then dynamically create a set of public, private, and data subnets across all specified availability zones using Terraform's for_each meta-argument. This is the key to a scalable and maintainable module—no more copying and pasting blocks for each AZ.

# main.tf in modules/vpc/

# Use locals for consistent naming and tagging
locals {
  common_tags = {
    Project   = var.project_name
    ManagedBy = "Terraform"
  }
}

# 1. Create the main VPC
resource "aws_vpc" "main" {
  cidr_block           = var.vpc_cidr_block
  enable_dns_support   = true
  enable_dns_hostnames = true

  tags = merge(
    local.common_tags,
    {
      Name = "${var.project_name}-vpc"
    }
  )
}

# 2. Create the Internet Gateway
resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id

  tags = merge(
    local.common_tags,
    {
      Name = "${var.project_name}-igw"
    }
  )
}

# 3. Create Elastic IPs and NAT Gateways for each AZ
resource "aws_eip" "nat" {
  for_each = toset(var.availability_zones)
  vpc      = true

  tags = merge(
    local.common_tags,
    {
      Name = "${var.project_name}-nat-eip-${each.key}"
    }
  )
}

resource "aws_nat_gateway" "main" {
  for_each      = toset(var.availability_zones)
  allocation_id = aws_eip.nat[each.key].id
  subnet_id     = aws_subnet.public[each.key].id

  tags = merge(
    local.common_tags,
    {
      Name = "${var.project_name}-nat-gw-${each.key}"
    }
  )

  depends_on = [aws_internet_gateway.main]
}

# 4. Dynamically create subnets and route tables for each tier and AZ
# We use cidrsubnet() to carve out smaller ranges from the main VPC CIDR
resource "aws_subnet" "public" {
  for_each          = { for i, az in var.availability_zones : az => i }
  vpc_id            = aws_vpc.main.id
  availability_zone = each.key
  cidr_block        = cidrsubnet(var.vpc_cidr_block, 8, each.value)

  tags = merge(
    local.common_tags,
    {
      Name = "${var.project_name}-public-subnet-${each.key}"
    }
  )
}

resource "aws_subnet" "private" {
  for_each          = { for i, az in var.availability_zones : az => i }
  vpc_id            = aws_vpc.main.id
  availability_zone = each.key
  cidr_block        = cidrsubnet(var.vpc_cidr_block, 8, length(var.availability_zones) + each.value)

  tags = merge(
    local.common_tags,
    {
      Name = "${var.project_name}-private-subnet-${each.key}"
    }
  )
}

resource "aws_subnet" "data" {
  for_each          = { for i, az in var.availability_zones : az => i }
  vpc_id            = aws_vpc.main.id
  availability_zone = each.key
  cidr_block        = cidrsubnet(var.vpc_cidr_block, 8, 2 * length(var.availability_zones) + each.value)

  tags = merge(
    local.common_tags,
    {
      Name = "${var.project_name}-data-subnet-${each.key}"
    }
  )
}

# ... Route table creation and associations would follow ...
# Public route table -> IGW
# Private route table -> NAT Gateway
# Data route table -> No default route

Architect's Note: Your VPC module isn't just a container for subnets; it's the foundation of your cloud's security posture. A critical mistake I see teams make is failing to enforce egress controls from the start. Use AWS Network Firewall or a third-party appliance and route all outbound traffic from your private subnets through it. By default, your containers and VMs can talk to the entire internet. This is a massive attack surface. By forcing traffic through a central inspection point, you can restrict egress to only approved domains (e.g., *.github.com, your payment provider's API). This single architectural choice can prevent a huge class of data exfiltration attacks and is a massive win for compliance frameworks like SOC 2 and ISO 27001.

outputs.tf

Outputs are the public API of your module. They expose key resource IDs so other modules (like a Kubernetes cluster module) can be deployed into the network we've just defined.

output "vpc_id" {
  description = "The ID of the created VPC."
  value       = aws_vpc.main.id
}

output "public_subnet_ids" {
  description = "A list of the public subnet IDs."
  value       = [for s in aws_subnet.public : s.id]
}

output "private_subnet_ids" {
  description = "A list of the private subnet IDs."
  value       = [for s in aws_subnet.private : s.id]
}

output "data_subnet_ids" {
  description = "A list of the data subnet IDs."
  value       = [for s in aws_subnet.data : s.id]
}

Pitfalls & Optimisations

Building the module is only half the battle. Using it wisely and avoiding common traps is what separates a senior engineer from a junior one.

  • Pitfall: CIDR Collisions. Never hardcode CIDR blocks in your consuming root modules. If you build two VPCs with an overlapping 10.0.0.0/16 range, you will never be able to peer them. This can be a catastrophic, unfixable mistake. Use a tool or a simple IPAM (IP Address Management) spreadsheet to plan your address space across your entire organization before you start.

  • Pitfall: Neglecting Tags. I can't stress this enough: untagged resources are ghosts. They don't show up in cost reports, you can't build IAM policies around them, and they are impossible to audit. Our module uses a common_tags local to enforce this, and you should make it a non-negotiable standard for all your Terraform code.

  • Optimisation: VPC Endpoints. NAT Gateways cost money, both for the managed service and for data processing. For services that talk heavily to AWS APIs like S3 or DynamoDB, use VPC Gateway Endpoints. This routes traffic over the AWS private network instead of the public internet. It's more secure, lower latency, and dramatically cheaper.

  • Optimisation: High-Availability NAT. Our example creates a NAT Gateway in each AZ. This is the high-availability pattern recommended by AWS. For development or cost-sensitive environments, you could modify the module to deploy a single NAT Gateway and have all private subnets route through it. This is a trade-off: you save money, but you create a single point of failure if that AZ has an issue.


"Unlocked" Summary & CTA

Unlocked: Your Key Takeaways

  • Codify, Don't Click: Stop creating networks in the AWS console. A reusable Terraform module is the only way to achieve a consistent, secure, and scalable network foundation.

  • Isolate with Tiers: The three-tier (public, private, data) subnet design is a simple but powerful pattern for enforcing network security zones.

  • Automate with for_each: Build your infrastructure dynamically based on variables like a list of AZs. This eliminates repetitive code and makes your module flexible.

  • Outputs are your API: A good module exposes the necessary resource IDs through outputs, allowing other modules to snap into place like LEGO bricks.

  • Tag Everything: A rigorous tagging strategy is essential for cost management, security, and operations.

A solid network foundation isn't just about infrastructure; it's about enabling developer velocity. By getting this right, you remove one of the biggest bottlenecks in the entire software delivery lifecycle.

If your team is drowning in network complexity and struggling to build a secure, repeatable cloud foundation, I specialize in architecting these audit-ready systems.

Email me for a strategic consultation: atif@devopsunlocked.dev

Explore my projects and connect on Upwork

0
Subscribe to my newsletter

Read articles from Atif Farrukh directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Atif Farrukh
Atif Farrukh