AWS VPC Networking: Subnets, NAT Gateways & Security Groups Explained

•12 min read•Subid Das
awsnetworkingdevopsvpccloud-infrastructure
AWS VPC Networking: Subnets, NAT Gateways & Security Groups Explained

VPC is AWS's networking layer. Misunderstand it, and your infrastructure is either insecure or unreachable. This guide covers production-grade VPC design.

VPC Fundamentals#

A VPC is an isolated network within AWS. You define IP address ranges, subnets, routing, and security.

VPC (10.0.0.0/16)
├── Public Subnet (10.0.1.0/24) - EC2, ALB
│   └── Internet Gateway → Internet
├── Private Subnet (10.0.2.0/24) - RDS, ElastiCache
│   └── NAT Gateway → Internet (one-way)
└── Database Subnet (10.0.3.0/24) - RDS cluster

Step 1: Create VPC & Subnets#

# main.tf - VPC and subnets
resource "aws_vpc" "main" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_hostnames = true
  enable_dns_support   = true

  tags = { Name = "main-vpc" }
}

# Public Subnet (AZ-a)
resource "aws_subnet" "public_a" {
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.1.0/24"
  availability_zone = "us-east-1a"

  map_public_ip_on_launch = true  # Auto-assign public IPs

  tags = { Name = "public-subnet-a" }
}

# Public Subnet (AZ-b) - High Availability
resource "aws_subnet" "public_b" {
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.2.0/24"
  availability_zone = "us-east-1b"

  map_public_ip_on_launch = true

  tags = { Name = "public-subnet-b" }
}

# Private Subnet (AZ-a)
resource "aws_subnet" "private_a" {
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.11.0/24"
  availability_zone = "us-east-1a"

  tags = { Name = "private-subnet-a" }
}

# Private Subnet (AZ-b)
resource "aws_subnet" "private_b" {
  vpc_id            = aws_vpc.main.id
  cidr_block        = "10.0.12.0/24"
  availability_zone = "us-east-1b"

  tags = { Name = "private-subnet-b" }
}

Subnet CIDR breakdown:

  • VPC: 10.0.0.0/16 = 65,536 IPs
  • Public-a: 10.0.1.0/24 = 256 IPs (250 usable)
  • Public-b: 10.0.2.0/24 = 256 IPs
  • Private-a: 10.0.11.0/24 = 256 IPs
  • Private-b: 10.0.12.0/24 = 256 IPs

Step 2: Internet Gateway (Public Access)#

# Internet Gateway - allows public traffic
resource "aws_internet_gateway" "main" {
  vpc_id = aws_vpc.main.id

  tags = { Name = "main-igw" }
}

# Public Route Table - route to IGW
resource "aws_route_table" "public" {
  vpc_id = aws_vpc.main.id

  route {
    cidr_block      = "0.0.0.0/0"  # All traffic
    gateway_id      = aws_internet_gateway.main.id
  }

  tags = { Name = "public-rt" }
}

# Associate public subnets with public route table
resource "aws_route_table_association" "public_a" {
  subnet_id      = aws_subnet.public_a.id
  route_table_id = aws_route_table.public.id
}

resource "aws_route_table_association" "public_b" {
  subnet_id      = aws_subnet.public_b.id
  route_table_id = aws_route_table.public.id
}

Now public subnets can reach the internet:

Public EC2 → Internet Gateway → AWS edge → Internet (0.0.0.0/0)

Step 3: NAT Gateway (Private Internet Access)#

Private instances can't reach internet directly. NAT Gateway provides one-way outbound access.

# Elastic IP for NAT Gateway
resource "aws_eip" "nat_a" {
  domain = "vpc"
  depends_on = [aws_internet_gateway.main]

  tags = { Name = "nat-eip-a" }
}

# NAT Gateway (in public subnet!)
resource "aws_nat_gateway" "nat_a" {
  allocation_id = aws_eip.nat_a.id
  subnet_id     = aws_subnet.public_a.id  # Must be public

  depends_on = [aws_internet_gateway.main]

  tags = { Name = "nat-gw-a" }
}

# Private Route Table
resource "aws_route_table" "private_a" {
  vpc_id = aws_vpc.main.id

  route {
    cidr_block     = "0.0.0.0/0"
    nat_gateway_id = aws_nat_gateway.nat_a.id
  }

  tags = { Name = "private-rt-a" }
}

# Associate private subnets
resource "aws_route_table_association" "private_a" {
  subnet_id      = aws_subnet.private_a.id
  route_table_id = aws_route_table.private_a.id
}

Important: Create one NAT per AZ for redundancy:

# NAT in AZ-b
resource "aws_eip" "nat_b" {
  domain = "vpc"
}

resource "aws_nat_gateway" "nat_b" {
  allocation_id = aws_eip.nat_b.id
  subnet_id     = aws_subnet.public_b.id
}

resource "aws_route_table" "private_b" {
  vpc_id = aws_vpc.main.id
  route {
    cidr_block     = "0.0.0.0/0"
    nat_gateway_id = aws_nat_gateway.nat_b.id
  }
}

resource "aws_route_table_association" "private_b" {
  subnet_id      = aws_subnet.private_b.id
  route_table_id = aws_route_table.private_b.id
}

Now private instances can download updates:

Private EC2 → NAT Gateway (in public subnet) → Internet Gateway → Internet
Incoming traffic → DROPPED (NAT is one-way)

Step 4: Security Groups (Firewalls)#

Security groups control inbound/outbound traffic.

# ALB Security Group
resource "aws_security_group" "alb" {
  name   = "alb-sg"
  vpc_id = aws_vpc.main.id

  ingress {
    from_port   = 80
    to_port     = 80
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]  # Allow HTTP from anywhere
  }

  ingress {
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]  # Allow HTTPS from anywhere
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]  # Allow all outbound
  }

  tags = { Name = "alb-sg" }
}

# Application Security Group
resource "aws_security_group" "app" {
  name   = "app-sg"
  vpc_id = aws_vpc.main.id

  # ALB → App servers
  ingress {
    from_port       = 3000
    to_port         = 3000
    protocol        = "tcp"
    security_groups = [aws_security_group.alb.id]  # Only from ALB
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = { Name = "app-sg" }
}

# Database Security Group
resource "aws_security_group" "rds" {
  name   = "rds-sg"
  vpc_id = aws_vpc.main.id

  # App servers → RDS
  ingress {
    from_port       = 5432
    to_port         = 5432
    protocol        = "tcp"
    security_groups = [aws_security_group.app.id]  # Only from app
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = { Name = "rds-sg" }
}

Traffic flow:

Internet → ALB (80/443) → App SG (3000) → RDS SG (5432) → Database

Each layer accepts traffic only from the previous layer. Defense in depth.

Network Architecture Diagram#

┌─────────────────────── VPC: 10.0.0.0/16 ──────────────────────┐
│                                                                 │
│  ┌──────────────────── Public Subnets ──────────────────────┐  │
│  │                                                           │  │
│  │  10.0.1.0/24 (AZ-a)        10.0.2.0/24 (AZ-b)          │  │
│  │  ┌──────────────────────┐   ┌──────────────────────┐    │  │
│  │  │ ALB                  │   │ ALB                  │    │  │
│  │  │ 10.0.1.10           │   │ 10.0.2.10           │    │  │
│  │  │ alb-sg               │   │ alb-sg               │    │  │
│  │  └──────────────────────┘   └──────────────────────┘    │  │
│  │           ↓                            ↓                  │  │
│  │  ┌──────────────────────┐   ┌──────────────────────┐    │  │
│  │  │ NAT Gateway          │   │ NAT Gateway          │    │  │
│  │  │ 10.0.1.5             │   │ 10.0.2.5             │    │  │
│  │  │ EIP: 54.1.1.1        │   │ EIP: 54.1.1.2        │    │  │
│  │  └──────────────────────┘   └──────────────────────┘    │  │
│  │                                                           │  │
│  └───────────────────────────────────────────────────────────┘  │
│           ↓ Internet Gateway (IGW) ↓                             │
│  ┌──────────────────── Private Subnets ─────────────────────┐   │
│  │                                                           │   │
│  │  10.0.11.0/24 (AZ-a)       10.0.12.0/24 (AZ-b)         │   │
│  │  ┌──────────────────────┐   ┌──────────────────────┐    │   │
│  │  │ App Server 1         │   │ App Server 2         │    │   │
│  │  │ 10.0.11.10           │   │ 10.0.12.10           │    │   │
│  │  │ app-sg               │   │ app-sg               │    │   │
│  │  └──────────────────────┘   └──────────────────────┘    │   │
│  │           ↓                            ↓                 │   │
│  │  ┌──────────────────────┐   ┌──────────────────────┐    │   │
│  │  │ RDS Primary          │   │ RDS Standby          │    │   │
│  │  │ 10.0.11.50           │   │ 10.0.12.50           │    │   │
│  │  │ rds-sg               │   │ rds-sg               │    │   │
│  │  └──────────────────────┘   └──────────────────────┘    │   │
│  │                                                           │   │
│  └───────────────────────────────────────────────────────────┘   │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘
                                 ↓
                            Internet

VPC Flow Logs (Troubleshooting)#

# Enable VPC flow logs to CloudWatch
resource "aws_flow_log" "main" {
  iam_role_arn    = aws_iam_role.flow_logs.arn
  log_destination = aws_cloudwatch_log_group.flow_logs.arn
  traffic_type    = "ALL"  # ALL, ACCEPT, REJECT
  vpc_id          = aws_vpc.main.id

  tags = { Name = "vpc-flow-logs" }
}

resource "aws_cloudwatch_log_group" "flow_logs" {
  name = "/aws/vpc/flow-logs"

  retention_in_days = 7
}

Query logs to debug connectivity:

-- Find rejected traffic
fields srcaddr, dstaddr, dstport, action
| filter action = "REJECT"
| stats count() by dstport

Best Practices#

✅ Do❌ Don't
Multi-AZ subnetsSingle AZ (single point of failure)
One NAT per AZOne NAT for all (bottleneck)
Security groups as firewallOpen all ports (0.0.0.0/0)
Small CIDR blocksOver-sized (hard to expand)
VPC Flow LogsNo logging (debug nightmares)
Private RDSPublic RDS (security risk)

Checklist#

  • VPC CIDR block (e.g., 10.0.0.0/16)
  • Public subnets in 2+ AZs
  • Private subnets in 2+ AZs
  • Internet Gateway for public traffic
  • NAT Gateways (one per AZ)
  • Route tables for public/private
  • Security groups for each tier
  • VPC Flow Logs enabled
  • NACLs configured (if needed)
  • VPC Endpoints for AWS services (optional, advanced)

Conclusion#

VPC design is foundational. Get it right once, scale to thousands of instances. Get it wrong, and you'll rewrite it later.

Use multi-AZ design from day one. Security groups are cheap. NAT Gateways cost ~$30/month—well worth the isolation.


Auditing an AWS environment that's outgrown its original VPC design? Book a 15-minute call.

FAQ

Do I need a NAT Gateway for every private subnet?

One NAT Gateway per AZ, shared across that AZ's private subnets, is the standard pattern — one per subnet is unnecessary cost, one for the whole VPC is a single point of failure across AZs.

What's the real cost of a NAT Gateway?

Roughly $30-35/month plus data processing charges per gateway — cheap relative to the isolation and multi-AZ resilience it buys.

Can I fix VPC design after launch, or does it need to be right from day one?

It can be fixed later, but rarely without downtime — re-architecting CIDR ranges, subnet layout, or routing tables under live traffic is riskier than getting the design right before the first instance launches.

About the author

Subid Dasis a Forward Deployed Engineer & Systems Architect building high-reliability cloud platforms, AI automations, and custom business software. Explore more technical articles on the blog, or get in touch if you're working on something similar.

Recommended Reading

Continue exploring production engineering guides, systems architecture, and cloud patterns.

Let's
build.

Tell me what’s slowing your business down. We’ll work out what to build next.

Tell me about your project

A few sentences are enough to start. No technical brief needed.

Service, budget & timeline (optional) +
I need help with
Budget (optional)
Timeline (optional)
LET'S CONNECT