If you've deployed anything on AWS, you've probably heard "VPC" thrown around in conversations without much explanation. A Virtual Private Cloud is essentially the network foundation where all your EC2 instances, RDS databases, and other resources live. Understanding VPC fundamentals means knowing how to segment your infrastructure, control traffic flow, and determine which resources can talk to the internet—and which shouldn't.
Over my years building production systems, I've seen misconfigured VPCs cause everything from mysterious connectivity issues to accidental exposure of private databases. Getting the basics right at the start saves you days of debugging later. Let me walk you through the core concepts you need to operate confidently.
Subnets — Public subnets route to the internet. Private subnets don't.
A subnet is a logical subdivision of your VPC's IP address range. When you create a VPC, you specify a CIDR block (say, 10.0.0.0/16), and then you carve it up into smaller subnets for different purposes. Think of it like this: your VPC is the entire building, and subnets are the different floors or departments.
A public subnet has a route to an Internet Gateway, meaning resources inside it can send traffic to and receive responses from the internet. An private subnet has no such route, so instances there cannot directly access the internet or be accessed from it.
Here's what matters in practice: I typically create a multi-AZ architecture with at least two availability zones. In each zone, I'll have a public subnet for load balancers and NAT gateways, and a private subnet for application servers and databases.
# Example VPC + subnet setup with AWS CLI
aws ec2 create-vpc --cidr-block 10.0.0.0/16
# Public subnet in AZ-a
aws ec2 create-subnet --vpc-id vpc-xxx --cidr-block 10.0.1.0/24 --availability-zone us-east-1a
# Private subnet in AZ-a
aws ec2 create-subnet --vpc-id vpc-xxx --cidr-block 10.0.2.0/24 --availability-zone us-east-1a
# Public subnet in AZ-b
aws ec2 create-subnet --vpc-id vpc-xxx --cidr-block 10.0.3.0/24 --availability-zone us-east-1b
# Private subnet in AZ-b
aws ec2 create-subnet --vpc-id vpc-xxx --cidr-block 10.0.4.0/24 --availability-zone us-east-1b
The key distinction: whether a subnet is public or private is determined by its route table, not anything about the subnet itself. If the route table has a route sending 0.0.0.0/0 traffic to an Internet Gateway, it's public. If there's no such route—or if the route goes to a NAT Gateway instead—it's private.
In my ride-hailing platform, I kept all user-facing services in public subnets (ALB, API Gateway integrations) and all data processing, worker queues, and databases in private subnets. This kept the attack surface minimal while maintaining full functionality.
Internet Gateway — Lets public subnets reach the internet.
An Internet Gateway (IGW) is an AWS-managed service that does two things: it translates private IP addresses to public IP addresses (and back) for resources in your VPC, and it provides the actual route to the internet.
You can't use an IGW without explicitly attaching it to your VPC. Once attached, you reference it in a route table rule. A resource in a public subnet still needs an Elastic IP or a public IP address to actually communicate outbound; the IGW handles the translation.
# Create and attach an Internet Gateway
aws ec2 create-internet-gateway
aws ec2 attach-internet-gateway --internet-gateway-id igw-xxx --vpc-id vpc-xxx
# Add a route in the public subnet's route table pointing to the IGW
aws ec2 create-route --route-table-id rtb-xxx --destination-cidr-block 0.0.0.0/0 --gateway-id igw-xxx
One thing I've learned: every VPC gets exactly one Internet Gateway, even if you have multiple public subnets. The IGW is a shared resource. What varies is which subnets' route tables point to it.
In a real scenario, an EC2 instance in a public subnet with a public IP makes an HTTP request. The IGW translates that instance's private IP (say, 10.0.1.50) to its public IP for the outbound journey, then translates responses back. From the instance's perspective, it's just sending packets; the IGW handles the network address translation (NAT) transparently.
NAT Gateway — Lets private subnets make outbound calls.
Here's the challenge: resources in private subnets have no direct route to the internet, but sometimes they need to download dependencies, pull Docker images, or call external APIs. That's where a NAT Gateway comes in.
A NAT Gateway lives in a public subnet and provides a route out for private subnets. You create a NAT Gateway, assign it an Elastic IP, then add a route in your private subnet's route table: send 0.0.0.0/0 traffic to that NAT Gateway. Now private instances can make outbound requests, but inbound connections still can't reach them from the internet.
# Allocate an Elastic IP for the NAT Gateway
aws ec2 allocate-address --domain vpc
# Create the NAT Gateway in the public subnet
aws ec2 create-nat-gateway --subnet-id subnet-xxx --allocation-id eipalloc-xxx
# Add a route in the private subnet's route table
aws ec2 create-route --route-table-id rtb-private --destination-cidr-block 0.0.0.0/0 --nat-gateway-id natgw-xxx
I always deploy a NAT Gateway per availability zone in production. This ensures that if one AZ fails, private subnets in other AZs can still reach the internet. It costs more, but the resilience is worth it—I've had to troubleshoot cascading failures at 2 AM before, and a well-architected NAT setup prevents that pain.
In my e-commerce platform, the application servers in private subnets needed to fetch product images from a CDN and call a third-party payment gateway. Without NAT Gateways, those calls would timeout. With them, traffic flows seamlessly: private instance → NAT Gateway → internet → external service → NAT Gateway → private instance.
Note: NAT Gateways are charged per hour plus data transfer costs. If you're cost-sensitive and don't need high availability, you can use a NAT instance (just an EC2 instance configured as a NAT), but it requires more management and doesn't scale as well.
Route tables — Decide where traffic goes for each subnet.
A route table is a set of rules (called routes) that determine where network traffic from your subnet or gateway is directed. Think of it as a routing configuration. Every subnet must be associated with a route table, and every route table is associated with a VPC.
Each route specifies a destination (CIDR block) and a target. The most common routes are:
- Local traffic (10.0.0.0/16 → local) — always present, allows communication within the VPC
- Internet-bound traffic (0.0.0.0/0 → IGW) — for public subnets
- Internet-bound traffic (0.0.0.0/0 → NAT Gateway) — for private subnets
- Specific routes to other resources (e.g., 10.1.0.0/16 → VPC peering connection)
AWS evaluates routes in order of specificity. If a packet matches multiple routes, the most specific one wins. For example, if your table has both 0.0.0.0/0 → IGW and 10.0.0.0/8 → VPC peering, a packet destined for 10.0.5.0 will go through the peering connection.
# List all routes in a route table
aws ec2 describe-route-tables --route-table-ids rtb-xxx
# Output example:
# {
# "RouteTables": [
# {
# "RouteTableId": "rtb-xxx",
# "Routes": [
# {
# "DestinationCidrBlock": "10.0.0.0/16",
# "GatewayId": "local",
# "State": "blackhole"
# },
# {
# "DestinationCidrBlock": "0.0.0.0/0",
# "GatewayId": "igw-xxx",
# "State": "active"
# }
# ]
# }
# ]
# }
I typically create three route tables:
- Public route table: Default route (0.0.0.0/0) → Internet Gateway. Associated with all public subnets.
- Private route table (per AZ): Default route (0.0.0.0/0) → NAT Gateway in that AZ. Associated with private subnets in the same AZ.
- Database route table (optional): Only local routes, for maximum isolation.
The per-AZ NAT Gateway approach matters because if you route all private subnets through a single NAT Gateway in a different AZ, you're adding latency and creating a single point of failure. I learned this the hard way on a high-traffic trading platform where cross-AZ traffic became a bottleneck.
In my current SaaS application, I use a simple setup: public route table with IGW, private route table with NAT Gateway per AZ. This keeps routing logic straightforward and debugging easy.
Common mistakes
Creating NAT Gateways in private subnets: NAT Gateways must live in public subnets. They need to route traffic through an Internet Gateway, which only public subnets can do. I've seen junior engineers spin up NAT Gateways in private subnets and wonder why nothing works.
Forgetting Elastic IPs for NAT Gateways: You must allocate an Elastic IP before creating a NAT Gateway. Without it, the gateway has no stable public address and can't function. The AWS CLI will reject the request, but it's still a common source of confusion.
Single NAT Gateway for multi-AZ: Placing one NAT Gateway in AZ-a and routing all private subnets through it introduces cross-AZ latency and creates a single point of failure. Always deploy one per AZ.
Overly permissive security groups with open subnets: Having a public subnet doesn't mean every instance there should accept inbound traffic from 0.0.0.0/0. Use security groups to restrict what's actually exposed. In my e-commerce platform, the ALB is in a public subnet, but its security group only opens port 443 to the internet. Application servers in private subnets are stricter still.
Confusing route table association: New users sometimes create a route table but forget to associate it with a subnet. The subnet then uses the VPC's main route table instead. Always explicitly associate route tables or modify the main one with intention.
Key takeaways
- Subnets are logical divisions of your VPC's IP space. Public subnets route to an Internet Gateway; private subnets don't.
- An Internet Gateway enables inbound and outbound internet connectivity for resources with public IPs in public subnets.
- A NAT Gateway (in a public subnet) allows private subnets to initiate outbound connections while staying unreachable from the internet.
- Route tables define how traffic flows from each subnet. Always route private subnets through a NAT Gateway in the same AZ to avoid latency and improve resilience.
- Use explicit route table associations and avoid relying on default or main route tables in production.
- Layer VPC architecture with security in mind: expose only what's needed, isolate databases, and keep private subnets truly private.






Comments
Loading comments…