Provisioning a Secure AWS VPC with Terraform
This tutorial builds a production-shaped AWS VPC in Terraform with public and private subnets across two availability zones, NAT gateways, route tables and least-privilege security groups. Along the way it covers the AWS defaults that cause outages when nobody changes them.
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:
Security groups: least-privilege ingress
Security groups built in the console usually have at least one 0.0.0.0/0 inbound rule that someone opened while debugging and never closed. I define two groups here. The first is for the public load balancer, and the second is for the application tier, which only accepts HTTPS from that load balancer.
resource "aws_security_group" "alb" {
name = "acme-prod-alb-sg"
description = "Public ALB - inbound HTTPS from the internet"
vpc_id = aws_vpc.main.id
ingress {
description = "HTTPS from the internet"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
description = "HTTPS to the application tier in the private subnets"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = [for s in aws_subnet.private : s.cidr_block]
}
tags = { Name = "acme-prod-alb-sg" }
}
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 {
description = "All outbound - deliberately open, see below"
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = { Name = "acme-prod-app-sg" }
}Using aws_security_group.alb.id as the ingress source instead of a CIDR block means only traffic from the load balancer's security group gets in, whatever IP range the ALB ends up in. The rule also documents itself. Six months from now it tells you which component is allowed to connect, and you don't have to work out what a range of addresses was for. The ALB's egress uses the private subnet CIDRs rather than aws_security_group.app.id, because two groups that reference each other inline create a dependency cycle. If you want SG-to-SG references on both sides, move the rules into standalone aws_vpc_security_group_ingress_rule / aws_vpc_security_group_egress_rule resources.
Egress on the application tier is another matter. I left it open to 0.0.0.0/0 to keep the example short, and because package mirrors, container registries and third-party APIs rarely have stable IP ranges. That isn't least privilege. Open egress is the path a compromised workload uses to exfiltrate data or call home. Once the environment is stable, I'd tighten it with VPC endpoints for the AWS services you use (S3, ECR, STS), explicit CIDRs or prefix lists for known destinations, and an egress proxy or AWS Network Firewall with a domain allowlist for the rest.
Every VPC comes with a default security group that allows all traffic between its members and all outbound traffic. Anything launched without an explicit group ends up in it. I bring it under Terraform and remove every rule so nobody can depend on it by accident.
# No ingress or egress blocks: Terraform removes every rule from the default SG
resource "aws_default_security_group" "default" {
vpc_id = aws_vpc.main.id
tags = { Name = "acme-prod-default-sg-do-not-use" }
}That's thirteen resource blocks and a few for_each loops, and the network already behaves like production. Losing an AZ degrades service instead of taking the whole environment down. Every rule traces back to a Git commit, and the next engineer can read the topology from the code 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
Event-Driven Apps with Lambda & EventBridge
Design an event-driven pipeline on Lambda and EventBridge that handles poison messages, retries and cold starts.
Secure Static Website on AWS S3 & CloudFront
Host a static site on S3 behind CloudFront with Origin Access Control, ACM TLS and a cache-invalidating deploy.
Refactoring Legacy Terraform for Teams
Move from one sprawling state file to versioned modules, safe migrations and policy-as-code guardrails.