VPC Networking from Scratch — Subnets, NAT, and Security Groups
A developer once asked me: "Why can't my Lambda function reach the internet after I put it in a VPC?" The answer took 15 minutes to explain. VPC networking is where most AWS engineers hit a wall — and it's because nobody taught them the fundamentals first.
CIDR Blocks — The Foundation of VPC Design
Every VPC needs a CIDR block that defines its IP address range. Think of it as the total pool of IP addresses available inside your virtual network.
| CIDR Block | IP Addresses | Typical Use |
|---|---|---|
/16 (e.g., 10.0.0.0/16) | 65,536 | Production VPC (room to grow) |
/20 (e.g., 10.0.0.0/20) | 4,096 | Medium applications |
/24 (e.g., 10.0.0.0/24) | 256 | Small dev/test environments |
Rule of thumb: Start with a /16 for production. You can't resize a VPC after creation, and running out of IP addresses is painful.
# Create a VPC with a /16 CIDR block
VPC_ID=$(aws ec2 create-vpc \
--cidr-block 10.0.0.0/16 \
--tag-specifications 'ResourceType=vpc,Tags=[{Key=Name,Value=production-vpc}]' \
--query 'Vpc.VpcId' \
--output text)
echo "VPC created: $VPC_ID"
# Enable DNS hostnames (required for many AWS services)
aws ec2 modify-vpc-attribute \
--vpc-id $VPC_ID \
--enable-dns-hostnames '{"Value": true}'
Public vs Private Subnets
This is the single most important networking concept in AWS. The difference between public and private subnets is simple: a public subnet has a route to an Internet Gateway. A private subnet does not.
# Create public subnets (two AZs for high availability)
PUB_SUBNET_1=$(aws ec2 create-subnet \
--vpc-id $VPC_ID \
--cidr-block 10.0.1.0/24 \
--availability-zone us-east-1a \
--tag-specifications 'ResourceType=subnet,Tags=[{Key=Name,Value=public-subnet-1a}]' \
--query 'Subnet.SubnetId' --output text)
PUB_SUBNET_2=$(aws ec2 create-subnet \
--vpc-id $VPC_ID \
--cidr-block 10.0.2.0/24 \
--availability-zone us-east-1b \
--tag-specifications 'ResourceType=subnet,Tags=[{Key=Name,Value=public-subnet-1b}]' \
--query 'Subnet.SubnetId' --output text)
# Create private subnets
PRIV_SUBNET_1=$(aws ec2 create-subnet \
--vpc-id $VPC_ID \
--cidr-block 10.0.10.0/24 \
--availability-zone us-east-1a \
--tag-specifications 'ResourceType=subnet,Tags=[{Key=Name,Value=private-subnet-1a}]' \
--query 'Subnet.SubnetId' --output text)
PRIV_SUBNET_2=$(aws ec2 create-subnet \
--vpc-id $VPC_ID \
--cidr-block 10.0.11.0/24 \
--availability-zone us-east-1b \
--tag-specifications 'ResourceType=subnet,Tags=[{Key=Name,Value=private-subnet-1b}]' \
--query 'Subnet.SubnetId' --output text)
Internet Gateway — The Door to the Internet
An Internet Gateway (IGW) allows resources in public subnets to communicate with the internet.
# Create and attach an Internet Gateway
IGW_ID=$(aws ec2 create-internet-gateway \
--tag-specifications 'ResourceType=internet-gateway,Tags=[{Key=Name,Value=production-igw}]' \
--query 'InternetGateway.InternetGatewayId' --output text)
aws ec2 attach-internet-gateway \
--internet-gateway-id $IGW_ID \
--vpc-id $VPC_ID
Route Tables — Traffic Directions
Route tables tell traffic where to go. Public subnets route 0.0.0.0/0 (everything) to the IGW. Private subnets route 0.0.0.0/0 to a NAT Gateway.
# Create a public route table
PUB_RT=$(aws ec2 create-route-table \
--vpc-id $VPC_ID \
--tag-specifications 'ResourceType=route-table,Tags=[{Key=Name,Value=public-rt}]' \
--query 'RouteTable.RouteTableId' --output text)
# Add route to Internet Gateway
aws ec2 create-route \
--route-table-id $PUB_RT \
--destination-cidr-block 0.0.0.0/0 \
--gateway-id $IGW_ID
# Associate public subnets with the public route table
aws ec2 associate-route-table --subnet-id $PUB_SUBNET_1 --route-table-id $PUB_RT
aws ec2 associate-route-table --subnet-id $PUB_SUBNET_2 --route-table-id $PUB_RT
# Enable auto-assign public IPs for public subnets
aws ec2 modify-subnet-attribute \
--subnet-id $PUB_SUBNET_1 \
--map-public-ip-on-launch
NAT Gateway — Private Subnet Internet Access
Resources in private subnets (databases, app servers) often need to reach the internet for updates, API calls, etc. A NAT Gateway provides outbound-only access — nothing from the internet can initiate a connection in.
# Allocate an Elastic IP for the NAT Gateway
EIP_ALLOC=$(aws ec2 allocate-address \
--domain vpc \
--query 'AllocationId' --output text)
# Create NAT Gateway in a public subnet
NAT_GW=$(aws ec2 create-nat-gateway \
--subnet-id $PUB_SUBNET_1 \
--allocation-id $EIP_ALLOC \
--tag-specifications 'ResourceType=natgateway,Tags=[{Key=Name,Value=production-nat}]' \
--query 'NatGateway.NatGatewayId' --output text)
# Wait for NAT Gateway to become available
aws ec2 wait nat-gateway-available --nat-gateway-ids $NAT_GW
# Create private route table with NAT Gateway route
PRIV_RT=$(aws ec2 create-route-table \
--vpc-id $VPC_ID \
--tag-specifications 'ResourceType=route-table,Tags=[{Key=Name,Value=private-rt}]' \
--query 'RouteTable.RouteTableId' --output text)
aws ec2 create-route \
--route-table-id $PRIV_RT \
--destination-cidr-block 0.0.0.0/0 \
--nat-gateway-id $NAT_GW
aws ec2 associate-route-table --subnet-id $PRIV_SUBNET_1 --route-table-id $PRIV_RT
aws ec2 associate-route-table --subnet-id $PRIV_SUBNET_2 --route-table-id $PRIV_RT
Cost alert: NAT Gateways cost ~$32/month plus $0.045/GB of data processed. For high-traffic workloads, consider NAT instances or VPC endpoints instead.
Security Groups vs NACLs
Both filter traffic, but they work very differently:
| Feature | Security Groups | NACLs |
|---|---|---|
| Level | Instance (ENI) level | Subnet level |
| State | Stateful (return traffic auto-allowed) | Stateless (must allow return traffic explicitly) |
| Rules | Allow rules only | Allow and Deny rules |
| Evaluation | All rules evaluated together | Rules processed in order by number |
| Default | Deny all inbound, allow all outbound | Allow all inbound and outbound |
| Use Case | Per-instance firewall | Subnet-wide deny rules, compliance |
In practice: use security groups for 90% of your firewall rules. Use NACLs only when you need to explicitly deny traffic (like blocking a specific IP range).
VPC Endpoints — Skip the Internet
When your private subnet resources need to access S3 or DynamoDB, don't route through NAT Gateway. Use VPC endpoints — they're free (for gateway type) and faster:
# Gateway endpoint for S3 (free)
aws ec2 create-vpc-endpoint \
--vpc-id $VPC_ID \
--service-name com.amazonaws.us-east-1.s3 \
--route-table-ids $PRIV_RT \
--vpc-endpoint-type Gateway
# Interface endpoint for Secrets Manager (costs ~$7.20/month per AZ)
aws ec2 create-vpc-endpoint \
--vpc-id $VPC_ID \
--service-name com.amazonaws.us-east-1.secretsmanager \
--subnet-ids $PRIV_SUBNET_1 $PRIV_SUBNET_2 \
--vpc-endpoint-type Interface \
--private-dns-enabled
VPC Flow Logs — See All Traffic
Flow logs capture metadata about IP traffic going to and from your VPC. Essential for troubleshooting connectivity issues and security auditing:
# Enable VPC flow logs to CloudWatch
aws ec2 create-flow-log \
--resource-type VPC \
--resource-id $VPC_ID \
--traffic-type ALL \
--log-destination-type cloud-watch-logs \
--log-group-name /aws/vpc/flow-logs \
--deliver-logs-permission-arn arn:aws:iam::123456789012:role/VPCFlowLogsRole
Flow logs tell you source IP, destination IP, port, protocol, action (ACCEPT/REJECT), and bytes transferred. If a connection is being rejected, flow logs show you exactly where and why.
What's Next?
You've built a complete VPC from scratch. Next up: AWS CLI like a pro — 30 commands that replace clicking through the console, with JMESPath queries and shell scripting tips.
This is Part 5 of our AWS series. Networking is the backbone of everything in the cloud — get it right once and everything else gets easier.
