The 3-tier architecture — web, application, and data, each isolated in its own subnet tier — is the default shape almost every real AWS deployment converges on. This walks through building it correctly, not just what each piece is.
- An AWS account with permissions to create VPCs, subnets, route tables, EC2 instances (or an ALB/ASG), and RDS.
- Familiarity with basic networking concepts (CIDR blocks, routing).
- The AWS CLI configured, or Terraform if following the IaC-based steps.
The target architecture
Each tier only accepts traffic from the tier directly above it. The web tier is the only one with a route to the internet at all; the app tier is reachable only from the ALB; the data tier is reachable only from the app tier. A compromised or misconfigured resource in one tier still can't directly reach the tier below the one it's supposed to talk to.
Step 1: Create the VPC and subnets
Spread subnets across at least two Availability Zones for real redundancy — a single-AZ setup defeats the purpose of a multi-tier architecture the moment that AZ has an issue.
aws ec2 create-vpc --cidr-block 10.0.0.0/1610.0.0.0/16 VPC
10.0.1.0/24 public-subnet-a (AZ: us-east-1a)
10.0.2.0/24 public-subnet-b (AZ: us-east-1b)
10.0.11.0/24 app-subnet-a (AZ: us-east-1a)
10.0.12.0/24 app-subnet-b (AZ: us-east-1b)
10.0.21.0/24 data-subnet-a (AZ: us-east-1a)
10.0.22.0/24 data-subnet-b (AZ: us-east-1b)
Step 2: Internet Gateway and NAT Gateways
The Internet Gateway attaches to the VPC and gives public subnets a route to the internet:
aws ec2 create-internet-gateway
aws ec2 attach-internet-gateway --vpc-id <vpc-id> --internet-gateway-id <igw-id>A NAT Gateway lives in each public subnet, giving the private app-tier subnets outbound-only internet access (for pulling package updates, calling external APIs) without exposing them to inbound connections:
aws ec2 allocate-address --domain vpc
aws ec2 create-nat-gateway --subnet-id <public-subnet-a-id> --allocation-id <eip-alloc-id>Repeat per AZ — one NAT Gateway in public-subnet-a, another in public-subnet-b — so an AZ outage doesn't take down outbound connectivity for the other AZ's app-tier instances too.
Step 3: Route tables per tier
public-route-table: 0.0.0.0/0 → Internet Gateway
app-route-table-a: 0.0.0.0/0 → NAT Gateway (in public-subnet-a)
app-route-table-b: 0.0.0.0/0 → NAT Gateway (in public-subnet-b)
data-route-table: (no 0.0.0.0/0 route at all)
The data tier's route table having no route to the internet is the actual enforcement mechanism — not a security group setting, a routing fact. Even if every security group on the database were accidentally set to allow all traffic, there is still no path from the public internet to that subnet at the network layer.
Step 4: Security groups, scoped tier-to-tier
Security groups referencing other security groups as their source (rather than IP ranges) is what keeps this maintainable as instances scale up and down — you're expressing "traffic from the app tier," not a specific, changing set of IPs.
# ALB security group: accepts HTTPS from the internet
aws ec2 create-security-group --group-name alb-sg --vpc-id <vpc-id>
aws ec2 authorize-security-group-ingress --group-id <alb-sg-id> \
--protocol tcp --port 443 --cidr 0.0.0.0/0
# App tier security group: accepts traffic only from the ALB
aws ec2 create-security-group --group-name app-sg --vpc-id <vpc-id>
aws ec2 authorize-security-group-ingress --group-id <app-sg-id> \
--protocol tcp --port 8080 --source-group <alb-sg-id>
# Data tier security group: accepts traffic only from the app tier
aws ec2 create-security-group --group-name data-sg --vpc-id <vpc-id>
aws ec2 authorize-security-group-ingress --group-id <data-sg-id> \
--protocol tcp --port 5432 --source-group <app-sg-id>| Aspect | IP-range-based rules | Security-group-referencing rules |
|---|---|---|
| Maintenance as instances scale | Must update rules as IPs change (autoscaling, replacements) | Automatically covers any instance in the referenced group |
| Readability | A CIDR block doesn't say what it represents | "From app-sg" states the intent directly |
| Typical use here | Only the ALB's public-facing rule (0.0.0.0/0) | Every tier-to-tier internal rule |
Step 5: Deploy the web tier — an Application Load Balancer
aws elbv2 create-load-balancer \
--name web-tier-alb \
--subnets <public-subnet-a-id> <public-subnet-b-id> \
--security-groups <alb-sg-id> \
--scheme internet-facingThe ALB is the only public entry point. It terminates HTTPS (using an ACM certificate — the same us-east-1-for-CloudFront caveat doesn't apply here; ALB certificates can be issued in whatever region the ALB itself is in) and forwards to targets in the private app-tier subnets.
Step 6: Deploy the app tier in private subnets
App-tier instances (or an ECS/EKS service — see Karpenter if running Kubernetes here) launch into app-subnet-a/app-subnet-b, registered as ALB targets, with no public IP assigned at all — they're only reachable via the ALB, and only reach the internet outbound via the NAT Gateway.
aws ec2 run-instances \
--subnet-id <app-subnet-a-id> \
--security-group-ids <app-sg-id> \
--no-associate-public-ip-address \
...--no-associate-public-ip-address is the field that actually matters here — a private subnet placement alone doesn't guarantee a private instance if it's still explicitly assigned a public IP.
Step 7: Deploy the data tier — RDS across two AZs
aws rds create-db-subnet-group \
--db-subnet-group-name data-tier-subnets \
--subnet-ids <data-subnet-a-id> <data-subnet-b-id>
aws rds create-db-instance \
--db-instance-identifier app-db \
--db-subnet-group-name data-tier-subnets \
--vpc-security-group-ids <data-sg-id> \
--multi-az \
--publicly-accessible false--publicly-accessible false is explicit and non-negotiable for this tier — even placed in a private subnet with no internet route, it's worth setting this flag deliberately rather than relying only on the routing table's absence of an internet route. --multi-az gives RDS a synchronously replicated standby in the second data subnet, so an AZ failure fails over automatically.
Scaling the app tier with an Auto Scaling Group
A single app-tier instance defeats the resilience benefit of a two-AZ layout. Placing an Auto Scaling Group across both app subnets, registered against the ALB target group, is what actually makes the architecture self-healing:
aws autoscaling create-auto-scaling-group \
--auto-scaling-group-name app-tier-asg \
--launch-template LaunchTemplateName=app-tier-template \
--vpc-zone-identifier "<app-subnet-a-id>,<app-subnet-b-id>" \
--target-group-arns <app-target-group-arn> \
--min-size 2 --max-size 6 --desired-capacity 2With min-size 2 spread across two AZs, the ASG maintains at least one healthy instance per AZ under normal conditions — losing an entire AZ still leaves the application serving traffic from the surviving one, backed by the ALB automatically routing only to healthy targets. Scaling policies (target tracking on CPU utilization or request count per target) then adjust desired-capacity between the configured min and max as real load changes, rather than running at max-size continuously.
If the app tier runs containers instead of raw EC2 instances, the same tier-isolation principles apply identically to an ECS service or EKS node group placed in the private app subnets — see Karpenter for how node provisioning works if that node group is Kubernetes-managed.
Verifying the isolation actually works
Before calling this done, verify each tier boundary is enforced, not just configured:
# From a bastion in the public subnet, confirm the app tier is reachable
# only on the intended port, not open more broadly
nmap -p 1-65535 <app-instance-private-ip>
# Confirm the data tier has no route to the internet at all
aws ec2 describe-route-tables --route-table-ids <data-route-table-id>The route table check should show no 0.0.0.0/0 entry at all for the data tier — if one exists, something (often a well-intentioned but incorrect manual edit) has quietly reintroduced an internet path that the architecture is specifically designed not to have.
Common mistakes
- Assigning a public IP to an app-tier instance "just to SSH in directly for debugging." This defeats the entire point of the private subnet — use a bastion host in the public subnet, or AWS Systems Manager Session Manager (no bastion, no open SSH port needed at all), instead.
- Running a single NAT Gateway for the whole VPC to save cost, without realizing it's both a single point of failure and, per the AWS cost optimization playbook, often a bigger cost driver than expected from its per-GB processing charge.
- Writing security group rules against specific IP addresses of instances instead of referencing other security groups — this breaks the moment autoscaling replaces an instance with a new IP.
- Forgetting
--publicly-accessible falseon RDS and assuming private subnet placement alone is sufficient. It's genuinely redundant with a correctly configured route table, but explicit is safer than implicit for a database holding real data.
Practical checklist
- One NAT Gateway per AZ in production, not a single shared one — the extra hourly cost is worth the redundancy for anything serving real traffic.
- Reference security groups by ID in tier-to-tier rules, never by CIDR block, except for the single public-facing ALB rule.
- Verify the data tier's route table has no
0.0.0.0/0route at all — this is the actual enforcement layer, and worth checking explicitly rather than assuming it from the subnet's "private" label. - Manage this entire setup with Terraform or CloudFormation rather than the AWS CLI shown here step by step — see Infrastructure as Code: Why Terraform Won for why that matters at this level of complexity.
Related reading
- Core AWS Services Every Developer Should Know — shares tags: cloud, devops (same category).
- AWS Cost Optimization: A Service-by-Service Playbook — shares tags: cloud, devops.
- Infrastructure as Code: Why Terraform Won — shares tags: cloud, devops.
- Karpenter: Faster, Simpler Kubernetes Autoscaling on AWS — shares tags: cloud.
- Host a Static Website on AWS S3 and CloudFront — shares tags: cloud.