Build a Complete AWS VPC with Terraform — Step by Step
Every AWS workload starts with a VPC. Click through the console once and you might get it right. Click through it for the fifth project and you will definitely get something wrong — a missing route, a misconfigured NAT gateway, a subnet that accidentally got a public IP. Terraform eliminates that problem. You define the network once, version it, and deploy identical VPCs across accounts, regions, and environments. This post builds a production-ready VPC from the ground up.
Architecture Overview
Here is what we are building:
- 1 VPC with DNS support
- 2 public subnets (one per AZ) — for load balancers and bastion hosts
- 2 private subnets (one per AZ) — for application servers and databases
- 1 Internet Gateway for public subnet internet access
- 1 NAT Gateway for private subnet outbound access
- Route tables wired correctly for each subnet type
- Security groups for web and application tiers
- A VPC endpoint for S3 (free and keeps traffic off the internet)
Provider and Variables
# versions.tf
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
}
# variables.tf
variable "aws_region" {
type = string
default = "us-east-1"
}
variable "environment" {
type = string
default = "production"
}
variable "vpc_cidr" {
type = string
default = "10.0.0.0/16"
description = "CIDR block for the VPC — gives us 65,536 IPs"
}
variable "availability_zones" {
type = list(string)
default = ["us-east-1a", "us-east-1b"]
}
variable "public_subnet_cidrs" {
type = list(string)
default = ["10.0.1.0/24", "10.0.2.0/24"]
}
variable "private_subnet_cidrs" {
type = list(string)
default = ["10.0.10.0/24", "10.0.20.0/24"]
}
CIDR planning tip: use /24 subnets (256 IPs each) and leave large gaps between public and private ranges. This makes it obvious which subnet is which when you see an IP address, and leaves room to add subnets later without re-CIDRing.
VPC and Subnets
# main.tf — VPC
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_support = true
enable_dns_hostnames = true
tags = {
Name = "${var.environment}-vpc"
Environment = var.environment
}
}
# Public subnets — one per AZ
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.environment}-public-${var.availability_zones[count.index]}"
Tier = "public"
}
}
# Private subnets — one per AZ
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]
tags = {
Name = "${var.environment}-private-${var.availability_zones[count.index]}"
Tier = "private"
}
}
Internet Gateway and NAT Gateway
Public subnets need an Internet Gateway for inbound and outbound traffic. Private subnets need a NAT Gateway for outbound-only access (software updates, API calls).
# Internet Gateway — free, one per VPC
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.environment}-igw"
}
}
# Elastic IP for NAT Gateway
resource "aws_eip" "nat" {
domain = "vpc"
tags = {
Name = "${var.environment}-nat-eip"
}
}
# NAT Gateway — ~$32/month + data processing charges
resource "aws_nat_gateway" "main" {
allocation_id = aws_eip.nat.id
subnet_id = aws_subnet.public[0].id
tags = {
Name = "${var.environment}-nat"
}
depends_on = [aws_internet_gateway.main]
}
Cost note: A single NAT Gateway costs roughly $32/month plus $0.045/GB processed. For dev environments, consider skipping the NAT Gateway entirely and using VPC endpoints instead. For production with high availability, deploy one NAT Gateway per AZ (multiply the cost by the number of AZs).
Route Tables
# Public route table — routes to Internet Gateway
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 = "${var.environment}-public-rt"
}
}
# Associate public subnets with public route table
resource "aws_route_table_association" "public" {
count = length(var.public_subnet_cidrs)
subnet_id = aws_subnet.public[count.index].id
route_table_id = aws_route_table.public.id
}
# Private route table — routes to NAT Gateway
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.main.id
}
tags = {
Name = "${var.environment}-private-rt"
}
}
# Associate private subnets with private route table
resource "aws_route_table_association" "private" {
count = length(var.private_subnet_cidrs)
subnet_id = aws_subnet.private[count.index].id
route_table_id = aws_route_table.private.id
}
Security Groups
# ALB security group — allows HTTP/HTTPS from the internet
resource "aws_security_group" "alb" {
name = "${var.environment}-alb-sg"
description = "Security group for Application Load Balancer"
vpc_id = aws_vpc.main.id
ingress {
description = "HTTP"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
description = "HTTPS"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${var.environment}-alb-sg"
}
}
# Application security group — only allows traffic from the ALB
resource "aws_security_group" "app" {
name = "${var.environment}-app-sg"
description = "Security group for application servers"
vpc_id = aws_vpc.main.id
ingress {
description = "App port from ALB"
from_port = 8080
to_port = 8080
protocol = "tcp"
security_groups = [aws_security_group.alb.id]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${var.environment}-app-sg"
}
}
Notice the app security group only accepts traffic from the alb security group — not from any IP address. This is the correct pattern for layered security.
VPC Endpoints
VPC endpoints let your private resources reach AWS services without going through the NAT Gateway. The S3 gateway endpoint is free.
# S3 Gateway Endpoint — free, keeps S3 traffic off the internet
resource "aws_vpc_endpoint" "s3" {
vpc_id = aws_vpc.main.id
service_name = "com.amazonaws.${var.aws_region}.s3"
route_table_ids = [
aws_route_table.private.id,
aws_route_table.public.id,
]
tags = {
Name = "${var.environment}-s3-endpoint"
}
}
Outputs
# outputs.tf
output "vpc_id" {
value = aws_vpc.main.id
description = "ID of the VPC"
}
output "public_subnet_ids" {
value = aws_subnet.public[*].id
description = "IDs of public subnets"
}
output "private_subnet_ids" {
value = aws_subnet.private[*].id
description = "IDs of private subnets"
}
output "alb_security_group_id" {
value = aws_security_group.alb.id
description = "Security group ID for ALB"
}
output "app_security_group_id" {
value = aws_security_group.app.id
description = "Security group ID for application servers"
}
output "nat_gateway_ip" {
value = aws_eip.nat.public_ip
description = "Public IP of the NAT Gateway"
}
Cost Summary
| Resource | Monthly Cost | Notes |
|---|---|---|
| VPC | Free | No charge for the VPC itself |
| Subnets | Free | No charge for subnets |
| Internet Gateway | Free | No hourly charge, data transfer applies |
| NAT Gateway | ~$32 + data | $0.045/hr + $0.045/GB processed |
| Elastic IP (in use) | Free | Charged only if unattached |
| S3 Gateway Endpoint | Free | Gateway endpoints have no charge |
| Security Groups | Free | No charge |
For a dev environment, the NAT Gateway is your biggest cost. Consider removing it and using VPC endpoints for the AWS services you need, or use NAT instances (cheaper but less reliable).
Wrapping Up
This VPC configuration is production-ready: multi-AZ subnets, proper route isolation, layered security groups, and a free S3 endpoint to reduce NAT costs. Package this as a module (as we covered in the previous post), and you can stamp out identical networks across every environment and region in minutes.
Next, we will tackle Terraform remote state — storing your state in S3 with DynamoDB locking so your team can collaborate safely without stepping on each other's changes.
