Deploying infrastructure on AWS starts with networking. While AWS offers automated wizards to generate VPC topologies in a single click, configuring one manually teaches you how internet gateways, route tables, subnets, and NAT gateways interact under the hood.
In this guide, we will design and deploy a production-grade VPC featuring:
-
A
/16CIDR block for plenty of address space. -
Two Public Subnets across two Availability Zones (AZs) for public-facing resources like load balancers or bastion hosts.
-
Two Private Subnets across two AZs for secure application servers and databases.
-
An Internet Gateway (IGW) for inbound/outbound internet routing.
-
A NAT Gateway allowing private instances to pull outbound updates safely without exposing them to incoming internet traffic.
Architecture Overview & IP Planning

Before touching the AWS Management Console, planning your CIDR blocks prevents overlapping IP conflicts later.
| Resource | CIDR Block | Availability Zone | Purpose |
| VPC | 10.0.0.0/16 |
Multi-AZ | Entire isolated network |
| Public Subnet 1 | 10.0.1.0/24 |
us-east-1a | Public ingress / ALB / NAT |
| Public Subnet 2 | 10.0.2.0/24 |
us-east-1b | Public ingress (High Availability) |
| Private Subnet 1 | 10.0.10.0/24 |
us-east-1a | Application backend / microservices |
| Private Subnet 2 | 10.0.20.0/24 |
us-east-1b | Application backend (High Availability) |
Note: AWS reserves the first 4 IP addresses and the last IP address in every subnet (5 IPs total). A
/24subnet gives you 251 usable host IPs.
Step 1: Create the VPC
-
Open the AWS Management Console and navigate to VPC.
-
Click Create VPC.
-
Under Resources to create, select VPC only (avoiding the automatic wizard).
-
Configure the settings:
-
Name tag:
production-vpc -
IPv4 CIDR block:
10.0.0.0/16 -
Tenancy:
Default
-
-
Click Create VPC.
-
Once created, select your new VPC, click Actions > Edit VPC settings, check both Enable DNS resolution and Enable DNS hostnames, and save changes.
Step 2: Create Public and Private Subnets
Navigate to Subnets in the left menu and click Create subnet. Select your production-vpc and create each of the following:
Public Subnets
-
Public-Subnet-1A:
-
Availability Zone:
us-east-1a -
IPv4 CIDR block:
10.0.1.0/24
-
-
Public-Subnet-1B:
-
Availability Zone:
us-east-1b -
IPv4 CIDR block:
10.0.2.0/24
-
Private Subnets
-
Private-Subnet-1A:
-
Availability Zone:
us-east-1a -
IPv4 CIDR block:
10.0.10.0/24
-
-
Private-Subnet-1B:
-
Availability Zone:
us-east-1b -
IPv4 CIDR block:
10.0.20.0/24
-
Enable Auto-Assign Public IPs for Public Subnets
By default, EC2 instances launched into a subnet will not receive a public IP address unless configured:
-
Select Public-Subnet-1A.
-
Click Actions > Edit subnet settings.
-
Check Enable auto-assign public IPv4 address.
-
Repeat this step for Public-Subnet-1B.
Step 3: Attach an Internet Gateway (IGW)
An Internet Gateway allows communication between your VPC and the outside internet.
-
In the VPC dashboard, select Internet gateways > Create internet gateway.
-
Name it
production-igwand click Create internet gateway. -
On the confirmation screen, click Actions > Attach to VPC.
-
Select
production-vpcand confirm the attachment.
Step 4: Configure the Public Route Table
A subnet only becomes “public” when its route table points default outbound traffic (0.0.0.0/0) to an Internet Gateway.
-
Navigate to Route tables > Create route table.
-
Name:
public-rt. -
Select
production-vpcand click Create route table. -
Select
public-rt, go to the Routes tab, and click Edit routes:-
Click Add route.
-
Destination:
0.0.0.0/0 -
Target: Select Internet Gateway, then choose
production-igw. -
Click Save changes.
-
-
Switch to the Subnet associations tab:
-
Click Edit subnet associations.
-
Select both Public-Subnet-1A and Public-Subnet-1B.
-
Click Save associations.
-
Step 5: Deploy a NAT Gateway for Private Subnets
Instances in your private subnets often need internet access (for downloading security updates, pulling packages, or hitting third-party APIs), but they must never accept unsolicited inbound connections.
-
Navigate to NAT gateways > Create NAT gateway.
-
Configure settings:
-
Name:
production-nat-gw -
Subnet: Select a Public Subnet (e.g.,
Public-Subnet-1A). A NAT Gateway must live in a public subnet to communicate with the internet. -
Connectivity type:
Public -
Elastic IP allocation ID: Click Allocate Elastic IP to generate a dedicated static public IP.
-
-
Click Create NAT gateway. (It takes 1–3 minutes to transition from Pending to Available).
Step 6: Configure the Private Route Table
Now, route private outbound traffic through the newly provisioned NAT Gateway.
-
Navigate to Route tables > Create route table.
-
Name:
private-rt. -
Select
production-vpcand click Create route table. -
Under the Routes tab, click Edit routes:
-
Click Add route.
-
Destination:
0.0.0.0/0 -
Target: Select NAT Gateway, then choose
production-nat-gw. -
Click Save changes.
-
-
Under the Subnet associations tab:
-
Click Edit subnet associations.
-
Check Private-Subnet-1A and Private-Subnet-1B.
-
Click Save associations.
-
Step 7: Verification & Security Best Practices
To ensure everything is working:
-
Launch a test EC2 instance in
Public-Subnet-1A: Confirm it gets a public IP and you can SSH into it. -
Launch a test EC2 instance in
Private-Subnet-1A: Verify it receives only a private IP (10.0.10.x). -
SSH from the Public instance to the Private instance using its private IP.
-
Test Outbound Connectivity: From inside the private instance, run:
curl -I https://aws.amazon.com
If it returns HTTP/2 200, your NAT Gateway route is resolving correctly while keeping the instance completely shielded from public discovery.
Production Tips:
-
Cost Optimization: A NAT Gateway incurs an hourly charge plus per-gigabyte data processing fees. For non-production dev environments, delete the NAT Gateway when not in use.
-
High Availability: For true production redundancy across AZs, deploy one NAT Gateway per Availability Zone and associate each with its respective private subnet route table. This ensures that if
us-east-1afails,us-east-1bcontinues operating uninterrupted.
Here is the complete, modular Terraform configuration implementing the VPC architecture defined in the guide.
File Structure
vpc-terraform/
├── main.tf
├── variables.tf
├── outputs.tf
└── terraform.tfvars
variables.tf
variable "aws_region" {
description = "AWS region for resources"
type = string
default = "us-east-1"
}
variable "vpc_cidr" {
description = "Base IPv4 CIDR block for the VPC"
type = string
default = "10.0.0.0/16"
}
variable "vpc_name" {
description = "Base name applied to resource tags"
type = string
default = "production-vpc"
}
variable "availability_zones" {
description = "List of Availability Zones to deploy subnets into"
type = list(string)
default = ["us-east-1a", "us-east-1b"]
}
variable "public_subnet_cidrs" {
description = "CIDR blocks for public subnets"
type = list(string)
default = ["10.0.1.0/24", "10.0.2.0/24"]
}
variable "private_subnet_cidrs" {
description = "CIDR blocks for private subnets"
type = list(string)
default = ["10.0.10.0/24", "10.0.20.0/24"]
}
main.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
}
# 1. VPC Definition
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
instance_tenancy = "default"
enable_dns_support = true
enable_dns_hostnames = true
tags = {
Name = var.vpc_name
}
}
# 2. Internet Gateway
resource "aws_internet_gateway" "gw" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.vpc_name}-igw"
}
}
# 3. Public Subnets
resource "aws_subnet" "public" {
count = length(var.public_subnet_cidrs)
vpc_id = aws_vpc.main.id
cidr_block = var.public_subnet_cidrs[count.index]
availability_zone = var.availability_zones[count.index]
map_public_ip_on_launch = true
tags = {
Name = "${var.vpc_name}-public-${var.availability_zones[count.index]}"
Tier = "Public"
}
}
# 4. Private Subnets
resource "aws_subnet" "private" {
count = length(var.private_subnet_cidrs)
vpc_id = aws_vpc.main.id
cidr_block = var.private_subnet_cidrs[count.index]
availability_zone = var.availability_zones[count.index]
map_public_ip_on_launch = false
tags = {
Name = "${var.vpc_name}-private-${var.availability_zones[count.index]}"
Tier = "Private"
}
}
# 5. Elastic IP for NAT Gateway
resource "aws_eip" "nat" {
domain = "vpc"
depends_on = [aws_internet_gateway.gw]
tags = {
Name = "${var.vpc_name}-nat-eip"
}
}
# 6. NAT Gateway (Placed in First Public Subnet)
resource "aws_nat_gateway" "nat" {
allocation_id = aws_eip.nat.id
subnet_id = aws_subnet.public[0].id
tags = {
Name = "${var.vpc_name}-nat-gw"
}
depends_on = [aws_internet_gateway.gw]
}
# 7. Public Route Table & Routing
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.gw.id
}
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
}
# 8. Private Route Table & Routing
resource "aws_route_table" "private" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.nat.id
}
tags = {
Name = "${var.vpc_name}-private-rt"
}
}
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.id
}
outputs.tf
output "vpc_id" {
description = "The ID of the provisioned VPC"
value = aws_vpc.main.id
}
output "public_subnet_ids" {
description = "IDs of the public subnets"
value = aws_subnet.public[*].id
}
output "private_subnet_ids" {
description = "IDs of the private subnets"
value = aws_subnet.private[*].id
}
output "nat_gateway_ip" {
description = "Public Elastic IP assigned to the NAT Gateway"
value = aws_eip.nat.public_ip
}
output "internet_gateway_id" {
description = "The ID of the attached Internet Gateway"
value = aws_internet_gateway.gw.id
}
terraform.tfvars
aws_region = "us-east-1" vpc_name = "production-vpc" vpc_cidr = "10.0.0.0/16" availability_zones = ["us-east-1a", "us-east-1b"] public_subnet_cidrs = ["10.0.1.0/24", "10.0.2.0/24"] private_subnet_cidrs = ["10.0.10.0/24", "10.0.20.0/24"]
Execution
terraform init
terraform plan
terraform apply