VPC/VNet segmentation for a three-tier app that survives audits
For developers and tech leads designing network boundaries for a web, app, and database stack. This guide shows how to segment a three-tier application inside a VPC or VNet with concrete routing, security-group/NSG, and firewall patterns, plus the trade-offs that matter when you have to operate it later.
TL;DR — For a three-tier app, the default safe layout is: public subnet only for the load balancer or reverse proxy, private app subnets with egress controlled through NAT or explicit firewall rules, and isolated database subnets with no direct internet route. The most common fix when teams get this wrong is to stop exposing app or DB nodes publicly and replace broad "allow from VPC" rules with explicit app-to-DB and LB-to-app rules. Reading time: ~7 min
What it is and where it sits
VPC/VNet segmentation for a three-tier application means deciding which network segments each tier lives in, which routes exist between them, and which packets are allowed at each hop. In practice, you are placing your internet-facing entry point, application workers, and database nodes into different subnets and attaching different policy layers: route tables, security groups or NSGs, and sometimes NACLs or subnet-level firewalls.
In a typical request flow, segmentation sits below your app code but above the physical network. It replaces the bad old pattern of "everything on one flat network with a few host firewalls". It also replaces the equally bad cloud anti-pattern of assigning public IPs to every VM because it is convenient for SSH or debugging.
What talks to it:
- Internet clients talk to the public entry point: load balancer, reverse proxy, API gateway, or ingress.
- The public entry point talks to app instances on app ports.
- App instances talk to the database on one DB port.
- Admin traffic ideally comes through a bastion, VPN, SSM-style session channel, or provider serial console path, not direct public SSH/RDP.
- Outbound app traffic for package repos, third-party APIs, SMTP relays, or object storage goes through NAT or explicit egress paths.
Where it lives in the request/data flow:
View as text
[Client] ---- 1. HTTPS 443 ----> [Public LB]
|
| 2. HTTP/TLS 8080/8443
v
[App Tier]
|
| 3. Postgres 5432
v
[DB Tier]
[App Tier] ---- 4. HTTPS egress via NAT ----> [Internet/API]
[Admin VPN] ---- 5. SSH/RDP/SSM ----> [Bastion or Mgmt Path]A clean segmentation design usually looks like this across at least two availability zones:
- Public subnets: load balancer, optionally bastion.
- Private app subnets: app VMs, containers, worker nodes.
- Isolated DB subnets: database instances, no default route to internet.
- Separate route tables per subnet class.
- Security groups/NSGs scoped to source security group or subnet CIDR, not broad RFC1918 ranges.
How it actually works
The mechanism is simple but easy to get subtly wrong:
- Subnets define IP ranges and attach to route tables.
- Route tables decide whether traffic stays local, goes to an internet gateway, NAT gateway, firewall appliance, VPN, or peering/transit path.
- Security groups/NSGs allow or deny flows based on source, destination, port, and protocol. In most clouds, these are stateful.
- Optional subnet ACLs add stateless filtering, which is useful rarely and misused often.
- DNS resolves internal names to private IPs so app nodes never need public addresses to reach the DB.
One realistic end-to-end example
Assume this target design:
- VPC/VNet CIDR:
10.20.0.0/16 - Public subnets:
10.20.0.0/24,10.20.1.0/24 - App subnets:
10.20.10.0/24,10.20.11.0/24 - DB subnets:
10.20.20.0/24,10.20.21.0/24 - Public LB listens on
443 - App listens on
8080 - Postgres listens on
5432
Step by step:
-
A client hits
https://app.example.com. DNS points to the public load balancer. The LB is in public subnets with a route like0.0.0.0/0 -> internet-gateway. -
The LB forwards to app targets in private app subnets. App subnets do not have a route to the internet gateway. They have either:
0.0.0.0/0 -> NAT gatewayfor outbound internet only, or- no default route at all if the app needs no internet egress.
-
The app queries Postgres at
db.internal.example. Internal DNS resolves that to a private address in a DB subnet. DB subnets have no internet route. Security policy allows only sourceapp-sgto destinationdb-sgontcp/5432. -
The app calls a third-party API. The packet goes from app subnet to NAT, then to the internet. Return traffic is tracked statefully and allowed back. The DB cannot do this because its subnet has no default route.
-
An engineer needs shell access. They connect through VPN or a bastion/mgmt path. There is no
22/tcpor3389/tcpopen to the world on app or DB nodes.
The key outcome is not just "private IPs". It is that every route and every allowed flow corresponds to a real dependency.
What breaks when segmentation is wrong
Common failure modes look like this:
- App nodes accidentally in public subnets with public IPs. Result: direct internet reachability, scanners hit your app port, and teams start debugging against instances instead of the LB path.
- DB security rule says "allow 5432 from 10.20.0.0/16". Result: every compromised workload in the VPC can probe the DB.
- NACL denies ephemeral return ports. Result: health checks or DB connections flap mysteriously.
- No NAT or egress path for app nodes. Result: package installs, OCSP/CRL checks, external API calls, or image pulls fail.
A realistic symptom on an app node with no egress route:
$ curl -I https://api.stripe.com/
curl: (7) Failed to connect to api.stripe.com port 443 after 21034 ms: No route to host
$ echo $?
7
A realistic symptom when the DB security rule is missing:
$ psql "host=db.internal.example port=5432 dbname=app user=app"
psql: error: connection to server at "10.20.20.15", port 5432 failed: timeout expired
$ echo $?
2
If DNS is wrong but network is fine:
$ getent hosts db.internal.example
$ echo $?
2
That distinction matters: timeout usually means routing/firewall; immediate "Name or service not known" means DNS.
When to use it (and when not to)
Use segmentation when the tiers have different exposure levels, different trust levels, or different compliance requirements. That is most production systems.
| Scenario | Recommendation |
|---|---|
| Public web app with app servers and a relational DB | Use 3 subnet classes: public LB, private app, isolated DB |
| Internal-only line-of-business app behind VPN | Skip public subnets for app; expose via internal LB; still isolate DB |
| Small prototype with one VM and SQLite | You probably don't need three tiers yet; use host firewall and no public DB |
| Kubernetes cluster running web/app/data services | Segment node subnets and use network policy; still isolate managed DB or stateful nodes |
| App requires heavy outbound internet access | Private app subnets plus NAT or egress firewall; monitor costs and destinations |
| Regulated environment with strict east-west controls | Add subnet or appliance firewalls and per-service policies, but keep the basic 3-tier layout |
You probably don't need elaborate segmentation if all of these are true:
- It is a short-lived prototype.
- There is no separate DB tier.
- You have one or two instances only.
- The blast radius of compromise is acceptable.
- You are willing to rebuild rather than harden.
You probably do need it if any of these are true:
- The DB contains customer or regulated data.
- Multiple teams deploy into the same network.
- You need audit evidence showing least privilege.
- You have internet-facing traffic and background workers in the same environment.
- You expect future peering, shared services, or hybrid connectivity.
Trade-offs
Every segmentation benefit has a cost.
- Smaller blast radius
- Cost: more rules to manage, more chances to break connectivity during deploys.
- Clear internet boundary
- Cost: NAT gateways, egress firewalls, or proxies add money and another failure domain.
- Easier audit story
- Cost: documentation and change control overhead; someone has to own the network map.
- Fewer accidental exposures
- Cost: debugging gets harder because you cannot just SSH to everything from your laptop.
- Better separation of duties
- Cost: more IAM/network coordination between app and platform teams.
- Predictable request path through LB -> app -> DB
- Cost: a little more latency than direct public-to-instance access, though usually negligible compared with app/database latency.
- Provider-native controls like SGs/NSGs
- Cost: some lock-in in rule semantics, references, and logging formats.
The big operational trade-off: security groups/NSGs are usually enough for most three-tier apps. Adding NACLs, appliance firewalls, and custom routing can improve control, but it also creates the classic "works in staging, times out in prod" problem. Start with the minimum layers that express your trust boundaries.
In practice
Example 1: Linux host firewall on an app VM
Even if your cloud security groups are correct, put a host-level backstop on app nodes.
sudo iptables -P INPUT DROP
sudo iptables -A INPUT -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
sudo iptables -A INPUT -i lo -j ACCEPT
sudo iptables -A INPUT -p tcp -s 10.20.0.0/23 --dport 8080 -j ACCEPT
sudo iptables -A INPUT -p tcp -s 10.99.0.0/24 --dport 22 -j ACCEPT
sudo iptables -A INPUT -p icmp -s 10.20.0.0/16 -j ACCEPT
sudo iptables -S
This allows app traffic from the public LB subnets on 8080, SSH only from a management subnet, and established return traffic. Gotcha: if your LB source addresses are not the subnet CIDRs you expect, health checks will fail; verify with ss -tnlp on the host and your provider's LB backend source behavior.
Example 2: Verify routes and DB reachability from an app node
ip route
getent hosts db.internal.example
nc -vz db.internal.example 5432
psql "host=db.internal.example port=5432 dbname=app user=app sslmode=require" -c 'select 1;'
Typical good output shape:
default via 10.20.10.1 dev eth0
10.20.0.0/16 dev eth0 proto kernel scope link src 10.20.10.34
10.20.20.15 db.internal.example
Connection to db.internal.example (10.20.20.15) 5432 port [tcp/postgresql] succeeded!
?column?
----------
1
(1 row)
This checks route presence, DNS, TCP reachability, and actual Postgres auth/TLS. Gotcha: nc succeeding does not prove the DB security posture is correct; it only proves tcp/5432 is open from this source.
Example 3: Terraform sketch for subnet classes and a tight DB rule
⚠️ Changing subnet CIDRs, route tables, or database subnet placement in a live environment can cause downtime. Apply this first in a non-production environment and confirm failover, backups, and connection strings before moving production traffic.
variable "vpc_cidr" { default = "10.20.0.0/16" }
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
}
resource "aws_subnet" "public_a" {
vpc_id = aws_vpc.main.id
cidr_block = "10.20.0.0/24"
map_public_ip_on_launch = true
}
resource "aws_subnet" "app_a" {
vpc_id = aws_vpc.main.id
cidr_block = "10.20.10.0/24"
}
resource "aws_subnet" "db_a" {
vpc_id = aws_vpc.main.id
cidr_block = "10.20.20.0/24"
}
resource "aws_security_group" "lb" {
name = "lb-sg"
vpc_id = aws_vpc.main.id
ingress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
}
resource "aws_security_group" "app" {
name = "app-sg"
vpc_id = aws_vpc.main.id
ingress {
from_port = 8080
to_port = 8080
protocol = "tcp"
security_groups = [aws_security_group.lb.id]
}
egress {
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
}
resource "aws_security_group" "db" {
name = "db-sg"
vpc_id = aws_vpc.main.id
ingress {
from_port = 5432
to_port = 5432
protocol = "tcp"
security_groups = [aws_security_group.app.id]
}
}
This expresses the important part: LB can receive from the internet, app only receives from LB, DB only receives from app. Gotcha: this is intentionally incomplete; you still need route tables, NAT/internet gateway resources, and multi-AZ duplication.
Further reading
- AWS Well-Architected Framework — Security Pillar
- Azure Architecture Center — Hub-spoke network topology
- Google Cloud Architecture Framework — Network security
- PostgreSQL Documentation — Secure TCP/IP Connections with SSL
- NIST SP 800-41 — Guidelines on Firewalls and Firewall Policy
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI