Sooner or later every developer on AWS meets the same error: the app can't reach the database, and the only clue is "connection timed out". The fix is usually one line in a security group or a route table, but finding it means knowing how the pieces fit together.
AWS networking is five ideas: a VPC is your private network, subnets split it across data centres, route tables decide where traffic can go, gateways connect it to the outside, and security groups decide who may talk to what. Here's each one in plain terms, the layout I use for small SaaS products, and how to debug it when something can't connect.
Part of my guide to AWS infrastructure for small SaaS teams.
TL;DR#
- A VPC is a private network in one Region, with an IP range (a CIDR block) between
/16and/28. Pick one that won't overlap any network you'll ever connect to. - A subnet is a slice of that range in one Availability Zone. AWS reserves 5 addresses in every subnet.
- A subnet is public only because its route table sends
0.0.0.0/0to an internet gateway. Everything else is private. - Private subnets reach the internet through a NAT gateway (about $33 a month plus $0.045 per GB in us-east-1). Gateway endpoints for S3 and DynamoDB are free and skip it.
- Security groups are stateful, allow-only firewalls on each resource, and can reference each other. NACLs are stateless, per-subnet and rarely needed.
- "Timed out" means something dropped the packets: a security group, NACL or route. "Refused" means you reached the machine and nothing was listening.
The mental model#
Region us-east-1
└─ VPC 10.42.0.0/16 ─────────────────────────── Internet gateway ── internet
├─ AZ a
│ ├─ public subnet 10.42.0.0/24 load balancer, NAT gateway
│ ├─ app subnet 10.42.16.0/20 EC2 instances / ECS tasks
│ └─ data subnet 10.42.64.0/24 RDS, Redis
└─ AZ b
├─ public subnet 10.42.1.0/24
├─ app subnet 10.42.32.0/20
└─ data subnet 10.42.65.0/24
Traffic from users enters through the internet gateway to the load balancer in the public subnets, which forwards it to the app in the private subnets, which talks to the database in the data subnets. Nothing outside the VPC can start a connection to the app or the database directly.
The VPC: your private network#
A VPC is an isolated network inside one AWS Region, defined by an IPv4 range in CIDR notation. 10.42.0.0/16 means "the first 16 bits are fixed", which leaves 16 bits, or 65,536 addresses. AWS allows anything from /16 (65,536 addresses) down to /28 (16).
Two decisions matter here:
- Use a
/16. Addresses inside a VPC are free, and running out later is painful. - Don't pick
10.0.0.0/16. It's the default in nearly every tutorial, so it's the range most likely to clash when you later connect this VPC to another one, a partner's network or an office VPN. Overlapping ranges can't be peered. Something like10.42.0.0/16avoids that.
Every account also has a default VPC in each Region (172.31.0.0/16) where every subnet is public. It's fine for experiments; for production, create your own with private subnets.
Subnets: one Availability Zone each#
A subnet is a slice of the VPC's range that lives in exactly one Availability Zone, one of the separate data centres in the Region. That's why high availability on AWS means "a subnet in each of two or more AZs": if one AZ has an outage, resources in the other keep running. Load balancers and RDS subnet groups require at least two AZs for exactly this reason.
AWS reserves five addresses in every subnet: the network address, the VPC router (.1), DNS (.2), one held for future use (.3) and the last address.
| Size | Addresses | Usable in a subnet | Good for |
|---|---|---|---|
/16 |
65,536 | (the VPC) | The whole VPC |
/20 |
4,096 | 4,091 | App subnets, especially with ECS or EKS, where every task or pod takes an IP |
/24 |
256 | 251 | Public and data subnets |
/28 |
16 | 11 | The smallest subnet AWS allows |
What makes a subnet public#
Nothing about a subnet itself is public or private. It's decided by its route table:
Public route table Private route table (one per AZ)
10.42.0.0/16 → local 10.42.0.0/16 → local
0.0.0.0/0 → igw-0abc… 0.0.0.0/0 → nat-0abc… (NAT gateway in the same AZ)
- Public subnet: its route table sends
0.0.0.0/0(everything that isn't local) to an internet gateway. A resource in it also needs a public IP to be reachable. - Private subnet: no route to an internet gateway. Nothing on the internet can reach it, whatever its security groups say.
The local route is always there: every subnet in a VPC can route to every other subnet. Whether traffic is allowed is up to security groups.
Public IPv4 addresses aren't free: since February 2024, AWS charges $0.005 an hour, about $3.65 a month, for each one, including those on load balancers and NAT gateways. Keep instances in private subnets and they don't need one.
Getting out of a private subnet: NAT gateways#
Private instances still need to reach the internet: to pull container images, install packages, call Stripe or send email. A NAT gateway in a public subnet lets them start outbound connections without accepting inbound ones.
It's also one of the most expensive lines on a small AWS bill. In us-east-1 a NAT gateway costs $0.045 an hour (about $33 a month) plus $0.045 per GB it processes. A NAT gateway lives in one AZ, so the highly available setup is one per AZ, which doubles the hourly cost.
Ways to keep it down:
- Start with one NAT gateway, shared by both AZs. You lose outbound internet in an AZ outage, which many small products can live with for a while. Cross-AZ traffic to it costs $0.01 per GB each way.
- Add free gateway endpoints for S3 and DynamoDB (next section), so that traffic never touches the NAT gateway.
- Use IPv6 for outbound traffic where your dependencies support it: an egress-only internet gateway has no hourly or processing charge.
How we cut infrastructure costs by 35% shows how much NAT traffic was costing us, and how we found it.
VPC endpoints: private roads to AWS services#
A VPC endpoint connects your VPC straight to an AWS service, without the internet or a NAT gateway.
- Gateway endpoints exist for S3 and DynamoDB only, and they're free. They're a route-table entry, so add the endpoint to every private route table. Container image layers from ECR are served from S3, so this also takes most of an image pull off the NAT gateway.
- Interface endpoints exist for most other services (ECR, Secrets Manager, SSM, CloudWatch Logs…). Each is a network interface in your subnets and costs about $0.01 an hour per AZ, plus $0.01 per GB. Add them when the traffic, or a no-internet requirement, justifies it.
Security groups: a firewall on every resource#
A security group is a firewall attached to a network interface: an instance, a load balancer, an RDS database, an ECS task.
- Allow rules only. Anything not allowed is denied. There's no "deny" rule.
- Stateful. If a request is allowed in, its response is allowed out automatically, and vice versa. You don't write rules for return traffic.
- Inbound denied, outbound allowed by default.
- They can reference each other. Instead of an IP range, a rule can allow traffic "from anything in security group
sg-app". This is the feature that makes AWS networking manageable.
A chain of three groups describes a whole web app:
| Security group | Allows in | From |
|---|---|---|
alb |
443 (and 80, to redirect) | 0.0.0.0/0 and ::/0 |
app |
3000 | security group alb |
db |
5432 | security group app |
Add a fourth instance to the app tier and it can reach the database the moment it joins sg-app. No IP addresses to update, and nothing else in the VPC can reach port 5432.
Network ACLs: the subnet-level backstop#
A network ACL filters traffic at the subnet boundary. It works differently from a security group:
| Security group | Network ACL | |
|---|---|---|
| Applies to | A network interface | A whole subnet |
| Rules | Allow only | Allow and deny, evaluated in number order |
| State | Stateful: responses allowed automatically | Stateless: responses need their own rule |
| Default | Deny in, allow out | The default NACL allows everything |
Because NACLs are stateless, allowing inbound 443 isn't enough: the responses leave on an ephemeral port, and your instance's own outbound requests get answers on one too. AWS recommends allowing ports 1024–65535 for that return traffic.
My advice for small teams: leave NACLs at their allow-all default and do everything with security groups. Reach for NACLs to block a specific abusive IP range across a subnet, or when a compliance framework asks for a second, subnet-level control.
A layout for a small SaaS#
The layout from the diagram, written out. It's what I use for a NestJS or Node.js API with PostgreSQL:
| Subnet | AZ | CIDR | Route to the internet | What runs there |
|---|---|---|---|---|
| public-a | a | 10.42.0.0/24 |
Internet gateway | Load balancer, NAT gateway |
| public-b | b | 10.42.1.0/24 |
Internet gateway | Load balancer |
| app-a | a | 10.42.16.0/20 |
NAT gateway | EC2 instances or ECS tasks |
| app-b | b | 10.42.32.0/20 |
NAT gateway | EC2 instances or ECS tasks |
| data-a | a | 10.42.64.0/24 |
None | RDS primary, Redis |
| data-b | b | 10.42.65.0/24 |
None | RDS standby |
The data subnets have no route out at all: a database has no reason to start a connection to the internet. The gaps between the ranges leave room for a third AZ or a new tier without renumbering anything.
The same thing in Terraform#
The security group chain, with the AWS provider's per-rule resources:
resource "aws_security_group" "alb" {
name = "alb"
vpc_id = aws_vpc.main.id
}
resource "aws_security_group" "app" {
name = "app"
vpc_id = aws_vpc.main.id
}
resource "aws_security_group" "db" {
name = "db"
vpc_id = aws_vpc.main.id
}
resource "aws_vpc_security_group_ingress_rule" "alb_https" {
security_group_id = aws_security_group.alb.id
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "tcp"
from_port = 443
to_port = 443
}
resource "aws_vpc_security_group_ingress_rule" "app_from_alb" {
security_group_id = aws_security_group.app.id
referenced_security_group_id = aws_security_group.alb.id
ip_protocol = "tcp"
from_port = 3000
to_port = 3000
}
resource "aws_vpc_security_group_ingress_rule" "db_from_app" {
security_group_id = aws_security_group.db.id
referenced_security_group_id = aws_security_group.app.id
ip_protocol = "tcp"
from_port = 5432
to_port = 5432
}
# Terraform removes AWS's default allow-all egress rule, so add back what each group needs.
resource "aws_vpc_security_group_egress_rule" "alb_to_app" {
security_group_id = aws_security_group.alb.id
referenced_security_group_id = aws_security_group.app.id
ip_protocol = "tcp"
from_port = 3000
to_port = 3000
}
resource "aws_vpc_security_group_egress_rule" "app_out" {
security_group_id = aws_security_group.app.id
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "-1"
}
The last comment catches many people: AWS creates new security groups with an allow-all outbound rule, but Terraform deletes it, so an app with no egress rule can't reach the database, the NAT gateway or anything else.
Debugging "it can't connect"#
Read the error first. It tells you where to look:
- "Connection timed out": the packets were dropped silently. Check, in order: the target's security group allows the port from the source's security group; the subnets' NACLs (if you changed them) allow the port and the ephemeral return range; the source's route table has a route to the destination; the target is in a subnet the source can route to.
- "Connection refused": the network worked and the machine answered, but nothing listens on that port and address. The app crashed, listens on a different port, or listens on
127.0.0.1inside a container instead of0.0.0.0. - "Could not resolve host": DNS. Inside the VPC, an RDS endpoint resolves to a private IP; check the VPC has DNS resolution and DNS hostnames turned on.
Two AWS tools save a lot of guessing:
- Reachability Analyzer analyses the path between two resources without sending traffic, and names the exact security group, NACL or route that blocks it. Each analysis costs $0.10.
- VPC Flow Logs record accepted and rejected connections per network interface. A
REJECTon the database's interface points straight at its security group or NACL.
FAQ#
What is the difference between a public and a private subnet in AWS?#
A public subnet's route table sends internet-bound traffic (0.0.0.0/0) to an internet gateway, so resources in it with a public IP can be reached from the internet. A private subnet has no route to an internet gateway, so nothing on the internet can reach it; its resources reach out through a NAT gateway, if they need to at all.
What is the difference between a security group and a network ACL?#
A security group is a stateful firewall on a network interface with allow rules only, and return traffic is allowed automatically. A network ACL filters a whole subnet, is stateless, so return traffic needs its own rules, and supports deny rules evaluated in number order. Most setups only need security groups.
Do I need a NAT gateway in my VPC?#
Only if resources in private subnets need to start connections to the internet, for example to call third-party APIs or install packages. It costs about $33 a month plus $0.045 per GB processed in us-east-1. Use free gateway endpoints for S3 and DynamoDB so that traffic skips it, and consider one shared NAT gateway before one per Availability Zone.
How many IP addresses does AWS reserve in a subnet?#
Five: the network address, the next three (for the VPC router, DNS and future use), and the last address. A /24 subnet has 256 addresses, of which 251 are usable.
What CIDR block should I use for my VPC?#
A /16 from the private ranges, such as 10.42.0.0/16, gives 65,536 addresses at no cost. Avoid 10.0.0.0/16 and any range used by other VPCs, offices or partners you might connect to later, because VPCs with overlapping ranges can't be peered.
Should I use the default VPC for production?#
It's better not to. Every subnet in the default VPC is public, so instances get public IPs and depend entirely on security groups. A custom VPC with private subnets for the app and database keeps them unreachable from the internet even if a security group is misconfigured.
