CloudAdvanced

Deploying a 3-Tier Application on AWS with Public and Private Subnets

A full walkthrough of the standard AWS 3-tier VPC architecture — web tier, app tier, and data tier, each in the right subnet, with security groups scoped correctly between them.

DevFieldGuideJuly 31, 20269 min read
Share:

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.

Prerequisites
  • 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

Internet GatewayEntry point for inbound public traffic
Public subnet (web tier)ALB and NAT Gateway — the only internet-facing layer
Private subnet (app tier)Application servers — no direct inbound internet route
Private subnet (data tier)RDS — reachable only from the app tier

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.

bash
aws ec2 create-vpc --cidr-block 10.0.0.0/16
10.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:

bash
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:

bash
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.

bash
# 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>
AspectIP-range-based rulesSecurity-group-referencing rules
Maintenance as instances scaleMust update rules as IPs change (autoscaling, replacements)Automatically covers any instance in the referenced group
ReadabilityA CIDR block doesn't say what it represents"From app-sg" states the intent directly
Typical use hereOnly 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

bash
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-facing

The 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.

bash
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

bash
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:

bash
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 2

With 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:

bash
# 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

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 false on 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

Best practices
  1. 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.
  2. Reference security groups by ID in tier-to-tier rules, never by CIDR block, except for the single public-facing ALB rule.
  3. Verify the data tier's route table has no 0.0.0.0/0 route at all — this is the actual enforcement layer, and worth checking explicitly rather than assuming it from the subnet's "private" label.
  4. 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.

Frequently Asked Questions

DevFieldGuide
DevFieldGuide

Editorial Team

Practical tutorials and developer tools, written and maintained by the DevFieldGuide team.

Enjoyed this article?

Get the next one straight to your inbox, along with the best of what we publish each week.

Related Articles

More in Cloud

View all