TerraformAWSIaCBeginnerNetworking

Provisioning a Secure AWS VPC with Terraform

Jerry RajaonarivoUpdated Sep 30, 20267 min read

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).

hcl
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).

hcl
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.

hcl
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.

hcl
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.

hcl
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.

hcl
# 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.

GO FURTHER

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
Get in TouchReply within 1 business day.

Related Tutorials