SUBID DASPORTFOLIO / 2026
000

SUBID

PORTRAIT // ORIGINAL[ 1 / 7 ]
WORK // RIDENOWW[ 2 / 7 ]
WORK // DEED POLL[ 3 / 7 ]
PORTRAIT // CYBERNETIC[ 4 / 7 ]
PORTRAIT // HOLOGRAM[ 5 / 7 ]
PORTRAIT // SKETCH[ 6 / 7 ]
PORTRAIT // GLITCH[ 7 / 7 ]
SUBID DAS
Back to Blog

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

12 min readSubid Das
awsnetworkingdevopsvpccloud-infrastructure

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.

About the author

Subid Das is a cloud native engineer and open source contributor. Find more articles onthe blog.

Open to freelance, full-time, and interesting problems.

LET'S CONNECT