Provisioning a Secure AWS VPC with Terraform
This builds a production-shaped AWS VPC in Terraform, public/private subnets across two availability zones, NAT gateways, route tables, and least-privilege security groups, covering the handful of defaults that quietly cause outages if left untouched.
VPC and CIDR planning
Before any Terraform gets written, I decide the address space, planning for the account to eventually hold more than one VPC. A /16 gives you 65,536 addresses, overkill for most workloads, but it means never having to touch the VPC CIDR again (changing it later means recreating the VPC).
resource "aws_vpc" "main" {
cidr_block = "10.20.0.0/16"
enable_dns_support = true
enable_dns_hostnames = true
tags = {
Name = "acme-prod-vpc"
Environment = "production"
ManagedBy = "terraform"
}
}enable_dns_hostnames is easy to skip and it's the reason a fresh VPC sometimes can't resolve internal service names or reach certain AWS-managed endpoints. I set it explicitly rather than trust the default.
Public and private subnets across two availability zones
A single-AZ subnet layout works fine right up until that AZ has a problem, at which point it's a full outage, not a network detail. Every environment I provision spans at least two AZs from day one, split into public subnets (for anything that needs a direct route to the internet: load balancers, NAT gateways) and private subnets (application servers, databases, internal services).
resource "aws_subnet" "public" {
for_each = {
"us-east-1a" = "10.20.0.0/24"
"us-east-1b" = "10.20.1.0/24"
}
vpc_id = aws_vpc.main.id
cidr_block = each.value
availability_zone = each.key
map_public_ip_on_launch = true
tags = {
Name = "acme-prod-public-${each.key}"
Tier = "public"
}
}
resource "aws_subnet" "private" {
for_each = {
"us-east-1a" = "10.20.10.0/24"
"us-east-1b" = "10.20.11.0/24"
}
vpc_id = aws_vpc.main.id
cidr_block = each.value
availability_zone = each.key
tags = {
Name = "acme-prod-private-${each.key}"
Tier = "private"
}
}Deploying into a single AZ "to keep things simple" doesn't remove the single-point-of-failure problem, it just moves it into the network layer, where it's less visible until the AZ actually goes down.
Internet Gateway and NAT Gateway for private egress
Public subnets reach the internet through an Internet Gateway. Private subnets still need outbound access (package installs, third-party API calls, pulling container images) without being directly reachable from the internet. That's what the NAT Gateway is for: it lives in a public subnet and lets private-subnet resources initiate outbound connections.
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = { Name = "acme-prod-igw" }
}
resource "aws_eip" "nat" {
for_each = aws_subnet.public
domain = "vpc"
tags = { Name = "acme-prod-nat-eip-${each.key}" }
}
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 = "acme-prod-nat-${each.key}" }
}A NAT Gateway bills per hour it's provisioned, plus per GB of data it processes. Running one per AZ for high availability quietly doubles that cost compared to the single shared NAT gateway most tutorials show. It's the right call for production, but make sure it's a deliberate one.
Route tables
Public subnets route 0.0.0.0/0 to the Internet Gateway; private subnets route it to the NAT Gateway in their own AZ, so a NAT failure in one zone doesn't take down egress in the other.
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 = "acme-prod-public-rt" }
}
resource "aws_route_table_association" "public" {
for_each = aws_subnet.public
subnet_id = each.value.id
route_table_id = aws_route_table.public.id
}
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 = "acme-prod-private-rt-${each.key}" }
}
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
}Here's the full topology once the gateways, subnets, and route tables are wired together:
A least-privilege security group
Console-built security groups usually carry at least one 0.0.0.0/0 rule opened during a debugging session and never revisited. The group below only allows what an application tier actually needs: HTTPS from a load balancer, nothing else in, and unrestricted outbound (safe by default, since it's outbound, not inbound).
resource "aws_security_group" "app" {
name = "acme-prod-app-sg"
description = "Application tier - inbound HTTPS from ALB only"
vpc_id = aws_vpc.main.id
ingress {
description = "HTTPS from the load balancer"
from_port = 443
to_port = 443
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 = "acme-prod-app-sg" }
}Referencing aws_security_group.alb.id as the ingress source instead of a CIDR block means only traffic actually coming from that load balancer's own security group gets in, no matter what IP range the ALB sits in. It's also self-documenting: six months from now, the security group tells you what is allowed to connect, rather than making you decode which numbers.
Five resource blocks and a couple of for_each loops, and the network already behaves like production: an AZ going down degrades service instead of taking the whole environment with it, the rules trace back to a Git commit instead of somebody's memory, and the next engineer can read the topology instead of reverse-engineering it.
Want to actually run this in production?
This tutorial covers the concepts and architecture. If you want to implement it in your own infrastructure, or get good enough to own this problem long-term, I offer 1:1 mentoring built around your real environment, not a generic course.
This tutorial
- Core architecture & key concepts
- Illustrative code snippets
- The reasoning behind each decision
1:1 mentoring
- Working sessions on your own environment
- Direct answers to the edge cases you're hitting
- Feedback on your actual implementation
- Ongoing support as you build it out
Related Tutorials
Refactoring Legacy Terraform for Multi-Team Governance
A practical path from one sprawling Terraform state file to versioned modules, safe migrations, and policy-as-code guardrails teams can share.
The 2026 Cloud Cost Optimization Playbook
A field-tested framework for cutting cloud spend without cutting reliability: rightsizing, committed-use discounts, and what makes savings stick.