Overview
A virtual private cloud (VPC) is a logically isolated network within a cloud provider, and everything about how traffic flows in, out, and around it is governed by explicit configuration: subnets divide the VPC's address space, route tables decide where traffic destined outside a subnet goes, and security groups/network ACLs filter what's allowed at the resource and subnet level. The recurring point of confusion is that "public" and "private" aren't inherent properties of a subnet; they're just descriptions of what its route table currently does, which means auditing actual routes (not subnet names) is the only reliable way to know what's actually internet-reachable. The same non-transitive routing trap shows up in peering itself, not just public/private subnet labeling, see VNet Peering Is Not Transitive (written for Azure VNets, but VPC peering in AWS has the identical limitation), and The AWS Bill Spiked and Nobody Had Launched Anything New for what a NAT gateway route actually costs in practice.
Quick Reference
| Concept | Purpose | Scope |
|---|---|---|
| VPC | Logically isolated virtual network | Account/region-wide container |
| Subnet | Address-space subdivision of a VPC | Availability zone |
| Route table | Determines where traffic destined outside a subnet goes | Per subnet |
| Security group | Stateful, per-resource allow-list firewall | Attached resources |
| Network ACL | Stateless, subnet-wide allow/deny rules | Per subnet |
Syntax
resource "aws_subnet" "private" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.2.0/24"
}
resource "aws_route" "private_nat" {
route_table_id = aws_route_table.private.id
destination_cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.main.id
}Examples
# Security group - stateful, per-resource. Allowing inbound 443
# automatically permits the matching outbound response traffic.
resource "aws_security_group_rule" "https_in" {
type = "ingress"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
security_group_id = aws_security_group.web.id
}Verifying a subnet's actual route beats trusting its name; this pulls the live route table rather than assuming from the console UI:
aws ec2 describe-route-tables \
--filters "Name=association.subnet-id,Values=subnet-0123456789abcdef0" \
--query 'RouteTables[*].Routes[*].[DestinationCidrBlock,GatewayId,NatGatewayId]'An igw-* GatewayId on the 0.0.0.0/0 route means public, regardless of what the subnet is named; a nat-* NatGatewayId means private-with-outbound; neither present means no internet route at all.
Trust route tables, not subnet names
A subnet named "private-subnet" with a route to an internet gateway is still public, regardless of its name. Always verify actual route table entries when auditing what's internet-reachable, naming conventions are not enforcement.
Visual Diagram
Common Mistakes
- Trusting a subnet's name ("private-subnet-1") instead of checking its actual route table; the two can silently disagree after a configuration change.
- Placing a resource that only needs outbound internet access directly in a public subnet with a public IP, exposing it to inbound traffic it never needed to accept.
- Over-relying on security groups alone and never using network ACLs as a defense-in-depth layer, or conversely, fighting stateless NACL rules without understanding why return traffic needs its own explicit allow.
- Forgetting that a security group allowing
0.0.0.0/0inbound on a sensitive port is a standing exposure, not a temporary convenience, if left in place after testing. - Assuming VPC peering is transitive: if VPC A peers with VPC B, and B peers with VPC C, resources in A still cannot reach C through B. Every pair that needs to communicate needs its own direct peering connection (or a transit gateway/hub-spoke design instead of a peering mesh), the exact same limitation that trips people up in Azure VNet peering.
Performance
- NAT gateways have their own throughput limits and per-GB data processing cost, very high-outbound-traffic workloads behind a single NAT gateway can hit real bottlenecks and cost, which is why multi-AZ NAT gateway placement matters at scale.
- VPC peering and transit gateway routing add hops between networks, designing the topology (hub-and-spoke vs. full mesh) affects both latency and the blast radius of a routing misconfiguration.
- Security group rule evaluation is effectively free at request time; it's a configuration correctness concern, not a runtime performance one.
Best Practices
- Audit actual route tables, not subnet naming conventions, when determining what's genuinely internet-reachable.
- Put anything that doesn't need to accept inbound internet traffic in a private subnet behind a NAT gateway for outbound access only.
- I use security groups as the primary per-resource control and network ACLs as a coarser, subnet-wide defense-in-depth layer, not the other way around.
- Design VPC topology (peering, transit gateway) deliberately for the traffic patterns you actually have, rather than defaulting to a full mesh that's harder to audit as it grows.
- I verify routes with the provider CLI/API (
describe-route-tablesor equivalent) as part of any network audit, not just the console UI, so the check is scriptable and repeatable. - Route AWS-service traffic (S3, DynamoDB) through a VPC gateway endpoint instead of a NAT gateway; it costs nothing extra and keeps that traffic off the NAT gateway's throughput limit and per-GB bill entirely.