Building an AWS VPC with Terraform: a step-by-step guide
Every application deployed to AWS needs a home—a Virtual Private Cloud where resources talk securely to each other and the wider internet. Getting networking right from day one saves you from painful refactors later. Building an AWS VPC with Terraform: a step-by-step guide walks you through the decisions and configurations that turn infrastructure-as-code into a production-ready network foundation.
Why VPCs matter for production teams
A VPC is more than a networking primitive; it's the blast radius boundary for your applications. When you define subnets, routing rules, and gateways in code, you make networking repeatable and auditable. You can spin up identical staging environments, test failover scenarios, and onboard new teams faster. Most importantly, you avoid the drift and inconsistency that happens when networking is done by hand in the console.
In this guide, we'll build a real-world VPC layout: public subnets that host load balancers and NAT gateways, private subnets for application servers and databases, and all the routing glue to tie it together. We'll use both raw Terraform and the battle-tested terraform-aws-modules/vpc module, so you understand what's happening under the hood.
CIDR planning: choosing a range that won't collide
The first decision is your CIDR block. This is the IP address range your VPC will use. The most common choice is 10.0.0.0/16, which gives you 65,536 addresses. Other common ranges are 172.16.0.0/12 and 192.168.0.0/16.
Why does it matter? If your company runs a VPN into on-premises systems, or peers VPCs together, overlapping CIDR ranges cause routing chaos. Before you write any Terraform, talk to your infrastructure team. Map out what ranges are already taken. If you're a startup, 10.0.0.0/16 is usually safe. If you're integrating with legacy systems, you might need 172.16.0.0/12 or a smaller slice like 10.100.0.0/16.
Once you've picked your VPC range, divide it into subnets. With 10.0.0.0/16, you can create sixteen /20 subnets (4,096 addresses each), or sixty-four /22 subnets (1,024 addresses each). Most teams use /24 subnets (256 addresses each) per availability zone, which is easier to reason about and leaves room to grow.
Here's a concrete plan for a 10.0.0.0/16 VPC across three availability zones:
10.0.1.0/24– public subnet, AZ-a10.0.2.0/24– private subnet, AZ-a10.0.11.0/24– public subnet, AZ-b10.0.12.0/24– private subnet, AZ-b10.0.21.0/24– public subnet, AZ-c10.0.22.0/24– private subnet, AZ-c
This pattern scales well. Public subnets start at x.1, private at x.2, making them easy to identify in code and console.
Public and private subnets: one of each per availability zone
Public subnets are routed directly to the internet via an Internet Gateway. Anything with a public IP in a public subnet is reachable from the internet. Private subnets have no direct internet route—traffic destined for the internet goes through a NAT gateway (if outbound access is needed) or stays internal.
Why separate them? Because security best practices say your databases, application servers, and message queues should never be directly exposed to the internet. A public subnet holds your load balancers and NAT gateways. Private subnets hold everything else.
Spanning multiple availability zones matters for high availability. If an entire AZ goes down, your infrastructure isn't crippled. You need at least one public and one private subnet per AZ; for resilience, we usually run the same redundancy in at least two AZs.
Here's the raw Terraform for this layout without a module:
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "production-vpc"
}
}
# Public subnets
resource "aws_subnet" "public" {
for_each = {
"a" = { cidr = "10.0.1.0/24", az = "us-east-1a" }
"b" = { cidr = "10.0.11.0/24", az = "us-east-1b" }
}
vpc_id = aws_vpc.main.id
cidr_block = each.value.cidr
availability_zone = each.value.az
map_public_ip_on_launch = true
tags = {
Name = "public-${each.key}"
}
}
# Private subnets
resource "aws_subnet" "private" {
for_each = {
"a" = { cidr = "10.0.2.0/24", az = "us-east-1a" }
"b" = { cidr = "10.0.12.0/24", az = "us-east-1b" }
}
vpc_id = aws_vpc.main.id
cidr_block = each.value.cidr
availability_zone = each.value.az
tags = {
Name = "private-${each.key}"
}
}
The key setting is map_public_ip_on_launch = true on public subnets. This auto-assigns a public IP to any instance launched there, without needing an Elastic IP.
Gateways: Internet Gateway and NAT
Internet Gateways and NAT Gateways are the doors between your VPC and the world.
Internet Gateway (IGW)
An Internet Gateway is attached to the VPC and added to the public route table. It translates between internal IP addresses and the internet, and it's stateless—it just forwards packets. You need exactly one per VPC.
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "production-igw"
}
}
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.main.id
}
tags = {
Name = "public-routes"
}
}
# Associate public subnets with the public route table
resource "aws_route_table_association" "public" {
for_each = aws_subnet.public
subnet_id = each.value.id
route_table_id = aws_route_table.public.id
}
That 0.0.0.0/0 route means "any traffic destined for an IP not in the VPC goes to the Internet Gateway."
NAT Gateway (for private subnet outbound access)
Private subnets have no direct internet route. If an instance in a private subnet needs to reach the internet (for package updates, API calls, etc.), it goes through a NAT Gateway. The NAT Gateway sits in a public subnet, holds an Elastic IP, and masquerades as the source for outbound traffic.
Each NAT Gateway costs about $32/month plus data transfer charges, so you need to decide: one NAT per AZ (more resilience, higher cost), or one shared NAT (lower cost, single point of failure for outbound). Most production systems use one per AZ.
# Elastic IPs for NAT gateways
resource "aws_eip" "nat" {
for_each = aws_subnet.public
domain = "vpc"
tags = {
Name = "nat-eip-${each.key}"
}
depends_on = [aws_internet_gateway.main]
}
# NAT gateways
resource "aws_nat_gateway" "main" {
for_each = aws_subnet.public
allocation_id = aws_eip.nat[each.key].id
subnet_id = each.value.id
tags = {
Name = "nat-${each.key}"
}
depends_on = [aws_internet_gateway.main]
}
# Private route table
resource "aws_route_table" "private" {
for_each = aws_subnet.private
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.main[each.key].id
}
tags = {
Name = "private-routes-${each.key}"
}
}
# Associate private subnets with their route tables
resource "aws_route_table_association" "private" {
for_each = aws_subnet.private
subnet_id = each.value.id
route_table_id = aws_route_table.private[each.key].id
}
Each private subnet gets its own route table pointing to its corresponding NAT Gateway in the same AZ. This ensures that if an AZ fails, private subnets there don't route to a NAT in a different AZ.
Using a module: terraform-aws-modules/vpc
The code above is correct but verbose. Most teams use the community-maintained terraform-aws-modules/vpc module, which encapsulates all these resources and best practices.
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.0"
name = "production"
cidr = "10.0.0.0/16"
azs = ["us-east-1a", "us-east-1b"]
private_subnets = ["10.0.2.0/24", "10.0.12.0/24"]
public_subnets = ["10.0.1.0/24", "10.0.11.0/24"]
enable_nat_gateway = true
single_nat_gateway = false # one NAT per AZ
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Environment = "production"
}
}
output "vpc_id" {
value = module.vpc.vpc_id
}
output "private_subnets" {
value = module.vpc.private_subnets
}
output "public_subnets" {
value = module.vpc.public_subnets
}
The module creates the VPC, subnets, Internet Gateway, NAT Gateways, Elastic IPs, and route tables—all with sensible defaults and tagging. Setting single_nat_gateway = false ensures one NAT per AZ.
The module also outputs subnet IDs, which you'll reference when launching EC2 instances, RDS databases, or other resources. This is how your VPC connects to the rest of your infrastructure.
Common mistakes
Overlapping CIDR blocks: Picking a range that collides with your VPN, on-premises network, or another VPC will cause silent routing failures. Always document your range in a shared spreadsheet or wiki.
Single NAT Gateway: Saving $32/month by using one NAT across all AZs means that AZ's failure silences all outbound traffic. Unless you're in dev/staging, always use one per AZ.
Forgetting to enable DNS: Set enable_dns_hostnames = true and enable_dns_support = true on the VPC. Without it, AWS-provided DNS names don't resolve, breaking service discovery and internal load balancers.
Launching instances in public subnets by default: It's easy to accidentally put an app server or database in a public subnet, especially under time pressure. Make private subnets the default in your Terraform modules and documentation.
Ignoring route table associations: A subnet isn't public or private until you associate it with a route table that has a route to an Internet Gateway or NAT. Easy to forget, impossible to debug without looking at the console.
Key takeaways
- Plan your CIDR block before writing code. Avoid collisions with VPNs, on-premises networks, and other VPCs.
10.0.0.0/16is a safe default for most teams. - Separate public subnets (load balancers, NAT gateways) from private subnets (app servers, databases) to enforce security boundaries.
- Span at least two availability zones with one public and one private subnet each for high availability.
- Use an Internet Gateway for public subnets and NAT Gateways for private subnet outbound access. One NAT per AZ unless you're in development.
- Prefer
terraform-aws-modules/vpcover raw resources. It bundles best practices, reduces boilerplate, and is actively maintained. - Enable DNS hostnames and support on the VPC. Many AWS services depend on it.
- Always output subnet IDs and VPC ID from your Terraform. Other modules will need them to launch resources.






Comments
Loading comments…