AWS Architecture

The Classic AWS Three-Tier Web Application Architecture: VPC, ALB, Auto Scaling and RDS Done Right

Ask ten AWS architects to whiteboard “a web application on AWS” and you get ten nearly identical drawings: DNS at the top, a load balancer in the middle, two rows of EC2 instances, a database at the bottom, everything duplicated across two Availability Zones. That drawing is the three-tier web application architecture, and it has been the default answer for two decades because it is the smallest design that survives the three failures that actually happen — an instance dying, a data centre going dark, and a database host failing — without a human touching anything at 2 a.m.

A three-tier architecture separates a web application into a presentation tier (what receives browser traffic), an application tier (where business logic runs) and a data tier (where state lives), each in its own network segment, each scaled and secured independently. On AWS the canonical mapping is Route 53 and CloudFront at the edge, an Application Load Balancer feeding an EC2 Auto Scaling group for the web tier, an internal load balancer and second Auto Scaling group for the app tier, and RDS Multi-AZ plus S3 for data — all inside a VPC laid out across two or three Availability Zones.

This article is the full walkthrough, in the order a request travels: the mental model first, then each component with the why behind every choice — subnet plan, NAT decision, ALB health checks, launch templates and scaling policies, Multi-AZ failover mechanics, the security-group chain, IAM roles, CloudWatch alarms — with real aws CLI, Terraform and CloudFormation, a what-fails-when table, a costed small deployment, and an honest section on when to use Lightsail, Beanstalk or serverless instead.

What problem this solves

Every three-tier architecture is an escape from the same starting point: one EC2 instance running nginx, the application and MySQL side by side. That box works brilliantly until the day it doesn’t, and its failure modes are all correlated — everything shares one machine, one disk, one IP, one Availability Zone.

Concern Single EC2 “everything box” Three-tier architecture
Instance failure Down until someone rebuilds it ALB routes around it; Auto Scaling replaces it in minutes
AZ outage Down for the duration (hours) Second AZ keeps serving; capacity rebuilds automatically
Traffic spike Vertical resize = downtime, with a biggest-instance ceiling Scale-out adds instances in minutes, no downtime
Deployments In-place on the live box; a bad deploy is an outage Rolling instance refresh; reversible per-instance
Database contention App and DB fight for the same CPU/RAM/IO DB isolated on RDS with its own sizing and failover
Security blast radius Web, SSH and DB on one host; one exploit owns everything Each tier firewalled; DB unreachable from the internet by construction

The deeper problem is coupled failure domains. On the single box, “the disk is full because of logs” takes down the database; “the app leaked memory” takes down the web server. Three-tier is not primarily about performance — it is about making each failure small, automatic to recover from, and invisible to users. It is also the background architecture behind half the AWS Solutions Architect Associate exam, so it rewards learning properly rather than by osmosis. Who hits this: every team whose “temporary” single instance became production, and anyone who inherited a pet server nobody dares reboot.

Learning objectives

By the end of this article you can:

Prerequisites & where this fits

You need an AWS account, comfort with the console and CLI, and the ability to read a CIDR block without panic. If subnets, route tables and gateways are hazy, read AWS VPC & Networking Fundamentals Explained first; if Regions and AZs are new, start with AWS Regions and Availability Zones.

This is the trunk of the AWS architecture track — the “why” behind the hands-on build in Your First Highly Available Web App on AWS, with a per-component deep dive linked from each section. Master this one pattern and most AWS reference architectures reveal themselves as variations: swap EC2 for containers, RDS for Aurora, add a queue between tiers, and you have half the enterprise catalogue.

Core concepts

Four ideas carry the entire architecture; get these and every later decision becomes obvious rather than memorised.

Tiers are failure and scale boundaries, not code folders. The web/app/data split gives each layer its own blast radius, scaling dial and security perimeter: web scales with request count, app with CPU-hungry logic, data completely differently (vertically, or with read replicas). Fusing them means scaling — and failing — them together. Small apps sometimes collapse web and app into one tier; the data separation is the non-negotiable one.

Stateless compute, stateful data — the golden rule. Any web- or app-tier instance must be killable at any moment with zero data loss: no sessions in instance memory (use ElastiCache, DynamoDB or a signed cookie), no uploads on instance disk (use S3), no database on the instance, logs shipped to CloudWatch Logs. An instance holding unique state turns Auto Scaling from a healer into a data-loss machine. Every three-tier war story — including the scenario below — violates this rule.

Availability Zones are the unit of failure you plan for. An AZ is one or more physically separate data centres; AWS’s contract is that AZs fail independently. So place two of everything stateless in different AZs and let managed services (ALB, RDS Multi-AZ) handle their own cross-AZ redundancy. Two AZs is the minimum; three adds headroom at slightly higher cross-AZ data cost.

Traffic passes through choke points you control. Client → Route 53 → CloudFront/WAF → ALB → web tier → internal ALB → app tier → RDS. Every arrow is a place to attach health checks, SG rules, metrics and TLS. The choke points turn a pile of instances into a system: the ALB decides who is healthy, the SG chain decides who may talk to whom, CloudWatch watches every hop.

The whole architecture as a responsibility table:

Tier What runs here AWS building blocks Scales by Holds state?
Edge DNS, CDN, TLS, filtering Route 53, CloudFront, ACM, AWS WAF Managed (global) No (cache only)
Web (presentation) Front-end app, dynamic pages ALB (public subnets) + EC2 ASG (private subnets) Requests → CPU target tracking Never
Application Business logic, APIs, jobs Internal ALB + EC2 ASG (private subnets) CPU / queue depth Never
Data Relational state, files, cache RDS Multi-AZ, S3 (ElastiCache optional) Vertically + read replicas; S3 scales itself Always — and only here

The network foundation: VPC, subnets and routing

Everything sits inside one VPC — your private, software-defined network. Give it a /16 (65,536 addresses): address space is free, renumbering later is misery. The three-tier layout rule is three ranks of subnets, repeated per AZ:

Subnet CIDR AZ Route for 0.0.0.0/0 What lives here
public-a 10.0.0.0/24 ap-south-1a Internet gateway ALB node, NAT gateway A
public-b 10.0.1.0/24 ap-south-1b Internet gateway ALB node, NAT gateway B
app-a 10.0.10.0/24 ap-south-1a NAT gateway A Web + app EC2 (AZ a)
app-b 10.0.11.0/24 ap-south-1b NAT gateway B Web + app EC2 (AZ b)
data-a 10.0.20.0/24 ap-south-1a — (no route) RDS primary or standby
data-b 10.0.21.0/24 ap-south-1b — (no route) RDS standby or primary

Leave CIDR gaps (10.0.2–9, 10.0.12–19) so a third AZ or new rank slots in without renumbering. Each layout decision is deliberate:

Decision The choice made Why The alternative, and why not
VPC size /16 (10.0.0.0/16) Room for years of growth; peering-friendly if non-overlapping /24 VPC: you will re-IP the estate within a year
AZ count 2 (3 for critical) Survives one AZ failure; matches ALB/RDS minimums 1 AZ: an AZ event is a full outage; 4+: cost with little gain
Instances in private subnets Always No public IPs on compute; attack surface is the ALB alone Public instances “for easy SSH”: use SSM instead
NAT gateway per AZ One per public subnet AZ loss doesn’t sever the surviving AZ’s egress; no cross-AZ data charge Single NAT: ~₹2,800/mo cheaper but a hidden cross-AZ dependency — dev only
Data subnets, no internet route Yes Databases never need the internet; removes an exfiltration class Reusing app subnets: loses the “DB cannot egress” guarantee
S3 from private subnets Gateway VPC endpoint Free, keeps S3 traffic off the NAT (~$0.045/GB processing) Via NAT: works, but you pay per GB for the privilege

The build, condensed to the commands that matter (IDs shortened; region ap-south-1):

# VPC + one subnet per rank in AZ a (repeat -a → -b for the second AZ)
aws ec2 create-vpc --cidr-block 10.0.0.0/16 \
  --tag-specifications 'ResourceType=vpc,Tags=[{Key=Name,Value=three-tier}]'
aws ec2 create-subnet --vpc-id vpc-0aa1 --cidr-block 10.0.0.0/24  --availability-zone ap-south-1a
aws ec2 create-subnet --vpc-id vpc-0aa1 --cidr-block 10.0.10.0/24 --availability-zone ap-south-1a
aws ec2 create-subnet --vpc-id vpc-0aa1 --cidr-block 10.0.20.0/24 --availability-zone ap-south-1a

# Internet gateway → default route for the public route table
aws ec2 create-internet-gateway
aws ec2 attach-internet-gateway --internet-gateway-id igw-0bb2 --vpc-id vpc-0aa1
aws ec2 create-route --route-table-id rtb-public \
  --destination-cidr-block 0.0.0.0/0 --gateway-id igw-0bb2

# NAT gateway (per AZ, needs an EIP) → default route for each private table
aws ec2 create-nat-gateway --subnet-id subnet-public-a --allocation-id eipalloc-0cc3
aws ec2 create-route --route-table-id rtb-app-a \
  --destination-cidr-block 0.0.0.0/0 --nat-gateway-id nat-0dd4

# Free S3 gateway endpoint so instance→S3 traffic skips the NAT
aws ec2 create-vpc-endpoint --vpc-id vpc-0aa1 --service-name com.amazonaws.ap-south-1.s3 \
  --route-table-ids rtb-app-a rtb-app-b

In Terraform the entire network is one well-worn module call — the rare case where the community module is genuinely the right answer:

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 5.0"

  name = "three-tier"
  cidr = "10.0.0.0/16"

  azs              = ["ap-south-1a", "ap-south-1b"]
  public_subnets   = ["10.0.0.0/24", "10.0.1.0/24"]
  private_subnets  = ["10.0.10.0/24", "10.0.11.0/24"]
  database_subnets = ["10.0.20.0/24", "10.0.21.0/24"]

  enable_nat_gateway     = true
  one_nat_gateway_per_az = true   # false in dev to save ~$33/mo per extra NAT
  enable_dns_hostnames   = true
}

Routing subtleties, endpoint types and NAT internals get their own treatment in the AWS VPC Deep Dive; the layout above is everything this architecture needs.

Traffic in: Route 53, CloudFront and the ALB

Route 53 owns the name. For the apex domain create an alias A record — Route 53’s record type that points at AWS resources (CloudFront, ALB) by name, follows their IP changes automatically, and costs nothing per query. Never hard-code an ALB’s IPs: they change. This architecture needs exactly one alias record; the rest is in Route 53 DNS: Records, Routing Policies and Health Checks.

CloudFront does two jobs: serving static assets (/static/*, images, JS, CSS) from an S3 origin so those requests never touch your EC2 fleet, and terminating TLS close to users. The bucket stays private — CloudFront reaches it via Origin Access Control (OAC) — and AWS WAF attaches to the distribution (or ALB) with managed rule groups, dropping SQL injection, XSS and known-bad clients before they consume a vCPU you pay for. Mechanics in the CloudFront Deep Dive.

The Application Load Balancer (ALB) is the heart of the web tier: a managed Layer-7 balancer AWS runs as redundant nodes across your (minimum two) public subnets. Three sub-components matter:

Health check setting Default Use here Why
Path / /healthz Dedicated endpoint proving the app can serve, not just that nginx is up
Interval 30 s 10 s Detect failure in tens of seconds, not minutes
Timeout 5 s 5 s Fine; raise only if /healthz does real work (it shouldn’t)
Unhealthy threshold 2 2 2 × 10 s → out of rotation ~20–30 s after failure
Healthy threshold 5 2 Back in rotation quickly; 5 × 10 s is needlessly slow
Success codes 200 200 Strict — a 302 login redirect must not count as healthy
Deregistration delay 300 s 30–60 s Drain time for in-flight requests; 300 s stalls every deploy

One design point worth making explicit: keep /healthz shallow (process up, config readable). If the probe also pings the database, a DB failover makes every instance report unhealthy at once and the ALB pulls your entire fleet during the exact 90 seconds you need it serving. Deep dependencies belong in alarms, not load-balancer probes.

Why an ALB and not an NLB or API Gateway? Because this is HTTP: you want path/host routing, health checks, WAF attachment, redirects and OIDC actions. NLB is for raw TCP/UDP at extreme scale with static IPs; API Gateway is for serverless APIs and gets expensive at steady volume. The full decision matrix is in ALB vs NLB vs API Gateway; every listener/rule/TLS option in the Elastic Load Balancing Deep Dive.

# Target group with tuned health checks
aws elbv2 create-target-group --name web-tg --protocol HTTP --port 80 \
  --vpc-id vpc-0aa1 --target-type instance \
  --health-check-path /healthz --health-check-interval-seconds 10 \
  --healthy-threshold-count 2 --unhealthy-threshold-count 2

# Internet-facing ALB across both public subnets
aws elbv2 create-load-balancer --name web-alb --type application \
  --scheme internet-facing --subnets subnet-public-a subnet-public-b \
  --security-groups sg-alb

# HTTPS listener with an ACM cert + HTTP→HTTPS redirect
aws elbv2 create-listener --load-balancer-arn $ALB_ARN --protocol HTTPS --port 443 \
  --certificates CertificateArn=$ACM_ARN \
  --ssl-policy ELBSecurityPolicy-TLS13-1-2-2021-06 \
  --default-actions Type=forward,TargetGroupArn=$TG_ARN
aws elbv2 create-listener --load-balancer-arn $ALB_ARN --protocol HTTP --port 80 \
  --default-actions 'Type=redirect,RedirectConfig={Protocol=HTTPS,Port=443,StatusCode=HTTP_301}'

The internal ALB between web and app tiers is the same resource with --scheme internal, in the private subnets, with its own target group and health checks — a stable DNS name for the web tier while app instances churn beneath it.

The compute tiers: launch templates and Auto Scaling

Both compute tiers follow the same recipe: a launch template (the versioned definition of “what an instance of this tier looks like”) plus an Auto Scaling group (ASG) (the controller keeping N healthy copies across AZs). Web and app tiers differ only in instance size, security group and user data.

Launch template setting Value here Why
AMI Amazon Linux 2023, or a baked golden AMI User-data bootstrap is fine to start; baked AMIs cut boot to seconds
Instance type t3.small (web) / t3.medium (app) Burstable suits spiky web traffic; t4g (Graviton) for ~20% less
Security group web-sg / app-sg The tier chain — see the security section
IAM instance profile Per-tier role SSM, CloudWatch agent, S3/Secrets — never access keys on disk
Metadata options HttpTokens=required (IMDSv2) Blocks the classic SSRF→credential-theft path
Public IP Disabled Private subnets; the ALB is the only front door
User data cloud-init: install app, start service Keep idempotent; debug via /var/log/cloud-init-output.log
EBS 20 GB gp3, encrypted, delete-on-termination Cheaper and faster at baseline than gp2; nothing precious lives here

The ASG settings that make the architecture self-healing:

For scaling policy, one choice is right for this pattern and the rest are special cases:

Policy type How it works Use when Gotcha
Target tracking “Hold average CPU at 50%” — AWS manages the alarms Default for request-driven tiers Metric must rise with load; scale-in is deliberately slow
Step scaling Your alarm thresholds, your step adjustments Asymmetric or multi-threshold needs You own alarm tuning forever
Scheduled Set min/desired at fixed times Known peaks, pre-warming a sale Blind to the unexpected — combine with target tracking
Predictive ML forecast pre-provisions capacity Strong daily/weekly periodicity Needs history; wrong for novel spikes
Manual / none Fixed desired count Steady internal apps HA without elasticity

Target 50% CPU rather than 80%: the headroom is the feature — it absorbs the minutes-long gap before new instances turn InService, and it covers losing an AZ (half your fleet) without immediate saturation.

aws ec2 create-launch-template --launch-template-name web-lt \
  --launch-template-data '{"ImageId":"ami-0abcd1234example","InstanceType":"t3.small",
    "SecurityGroupIds":["sg-web"],"IamInstanceProfile":{"Name":"web-tier-profile"},
    "MetadataOptions":{"HttpTokens":"required"},"UserData":"<base64 cloud-init>"}'

aws autoscaling create-auto-scaling-group --auto-scaling-group-name web-asg \
  --launch-template LaunchTemplateName=web-lt,Version='$Latest' \
  --min-size 2 --max-size 6 --desired-capacity 2 \
  --vpc-zone-identifier "subnet-app-a,subnet-app-b" \
  --target-group-arns $TG_ARN \
  --health-check-type ELB --health-check-grace-period 120

aws autoscaling put-scaling-policy --auto-scaling-group-name web-asg \
  --policy-name keep-cpu-50 --policy-type TargetTrackingScaling \
  --target-tracking-configuration '{
    "PredefinedMetricSpecification": {"PredefinedMetricType": "ASGAverageCPUUtilization"},
    "TargetValue": 50.0
  }'

Deployments ride the same machinery: publish a new launch-template version, run an instance refresh (aws autoscaling start-instance-refresh), and the ASG rolls the fleet gradually, honouring health checks — a bad build fails its checks and the refresh halts instead of completing the outage for you. Lifecycle hooks, warm pools and mixed instances live in EC2 Auto Scaling: Launch Templates, Policies and Lifecycle; instance internals in the EC2 Deep Dive.

The data tier: RDS Multi-AZ and S3

RDS runs the relational database as a managed instance: AWS owns the OS, patching, backups and — the part you cannot script yourself at 2 a.m. — failover. The canonical choice here: MySQL or PostgreSQL on a db.t4g.medium, gp3 storage, Multi-AZ.

Multi-AZ (instance deployment) means one primary in data-a and one synchronous standby replica in data-b — every committed write lands on both before the commit returns. The standby is invisible (not queryable) until the primary fails or you patch with failover, at which point RDS promotes it and flips the DNS behind the endpoint name. Your application keeps using app-db.xxxxx.ap-south-1.rds.amazonaws.com; after a typical 60–120 seconds of failed connections, new ones land on the promoted standby. Two habits make this seamless: connect by endpoint name (never a resolved IP) and use a connection pool that retries and re-resolves DNS. The newer Multi-AZ DB cluster flavour (two readable standbys, ~35 s failovers) is the upgrade path when the single-standby model pinches.

Know what each RDS durability mechanism is for — mixing them up is the most common design error in reviews:

Mechanism Replication Readable? Purpose Endpoint behaviour Cost effect
Multi-AZ standby Synchronous No Availability — automatic failover Same endpoint, DNS flips ~2× instance + storage
Read replica Asynchronous Yes Read scaling, reporting, cross-region Separate endpoint each +1 instance each
Backups / PITR Daily snapshot + 5-min logs No Recovery from bad data Restore creates a new instance Storage only (1–35 day retention)

Multi-AZ does not protect you from DELETE FROM orders — that replicates synchronously too, with great efficiency. Backups (7–35 day retention) plus deletion protection are non-negotiable companions. Set the DB subnet group to the data subnets, --no-publicly-accessible, and let RDS keep the master password in Secrets Manager (--manage-master-user-password) so no credential lands in user data or a repo.

# CloudFormation: the data tier in one resource
Database:
  Type: AWS::RDS::DBInstance
  DeletionPolicy: Snapshot
  Properties:
    DBInstanceIdentifier: app-db
    Engine: mysql
    EngineVersion: "8.0"
    DBInstanceClass: db.t4g.medium
    MultiAZ: true
    AllocatedStorage: 50
    StorageType: gp3
    StorageEncrypted: true
    MasterUsername: admin
    ManageMasterUserPassword: true
    DBSubnetGroupName: !Ref DataSubnetGroup
    VPCSecurityGroups: [!Ref DbSecurityGroup]
    BackupRetentionPeriod: 7
    DeletionProtection: true
    PubliclyAccessible: false

S3 completes the data tier as the home for everything that is a file: static assets served via CloudFront/OAC, user uploads (via the SDK, or straight from browsers with presigned URLs so uploads never transit your instances), logs and backups. Turn on versioning and a lifecycle rule; S3 gives eleven-nines durability for the price of a coffee. Engines and replicas: RDS & Aurora Deep Dive; the SQL-vs-NoSQL fork: RDS vs DynamoDB vs Aurora; buckets: S3 Deep Dive.

Security: the security-group chain and IAM roles

Security groups (SGs) are stateful instance-level firewalls, and the pattern that makes three-tier work is the chain: each tier’s SG allows inbound traffic only from the SG of the tier above it — referenced by ID, never by CIDR. SG references follow membership automatically: when the ASG scales 2 → 6, the rules already cover the new four. CIDR rules between tiers are how people end up with 10.0.0.0/8 allow all three months later.

Security group Inbound rule Source Why
alb-sg TCP 443, TCP 80 0.0.0.0/0 (or CloudFront prefix list) The only internet-facing surface; 80 exists solely to redirect
web-sg TCP 80 alb-sg Web instances accept the ALB and nothing else — not even each other
int-alb-sg TCP 8080 web-sg Internal ALB accepts only the web tier
app-sg TCP 8080 int-alb-sg App instances accept only the internal ALB
db-sg TCP 3306 app-sg The database accepts only the app tier; the internet cannot route here

Note what is absent: port 22. Nothing here needs SSH — SSM Session Manager gives shell access through the instance role with audit logging, no bastion, no key pairs, no inbound rules. Restrict alb-sg to the AWS-managed CloudFront origin-facing prefix list and users cannot bypass the WAF by hitting the ALB directly. Leave NACLs at default allow-all — a coarse, stateless second layer you reach for deliberately; trade-offs in Security Groups and NACLs Deep Dive.

IAM roles replace credentials everywhere. Each tier’s instance role grants exactly what that tier does: the web tier typically needs only AmazonSSMManagedInstanceCore and CloudWatchAgentServerPolicy; the app tier adds scoped S3 access and secretsmanager:GetSecretValue on the one DB secret ARN. No IAM users, no access keys in files. With IMDSv2 enforced, code gets short-lived credentials automatically and an SSRF hole no longer leaks them trivially. Evaluation mechanics: IAM Fundamentals.

Observability: the CloudWatch alarms that matter

The architecture heals itself, but you still need to know it happened — an auto-replaced instance at 3 a.m. is fine once and a pattern by the fifth time. Wire every alarm to an SNS topic. These cover the real failure surface:

Alarm Metric (namespace) Starting threshold What it catches
ALB 5xx spike HTTPCode_ELB_5XX_Count (AWS/ApplicationELB) >10 in 5 min ALB can’t get good answers — fleet-wide problem
Target 5xx spike HTTPCode_Target_5XX_Count >20 in 5 min Your app is throwing errors
Unhealthy hosts UnHealthyHostCount (per target group) ≥1 for 5 min Instance out of rotation and not recovering
Slow responses TargetResponseTime p95 >1 s for 10 min Degradation before users notice
CPU sustained CPUUtilization (AWS/EC2, per ASG) >80% for 15 min Scaling can’t keep up, or max-size ceiling hit
RDS CPU CPUUtilization (AWS/RDS) >80% for 15 min Query regressions, missing index, undersized class
RDS storage FreeStorageSpace <10% Full storage stops writes; enable storage autoscaling too
RDS connections DatabaseConnections >80% of max App-tier pool leak — precedes an outage

Subscribe to RDS events too (failover, low storage), so you know a failover happened even though the app survived it.

aws cloudwatch put-metric-alarm --alarm-name alb-target-5xx \
  --namespace AWS/ApplicationELB --metric-name HTTPCode_Target_5XX_Count \
  --dimensions Name=LoadBalancer,Value=app/web-alb/50dc6c495c0c9188 \
  --statistic Sum --period 300 --evaluation-periods 1 --threshold 20 \
  --comparison-operator GreaterThanThreshold \
  --alarm-actions arn:aws:sns:ap-south-1:111122223333:ops-alerts

Add the CloudWatch agent (via the instance role) for memory and disk metrics — EC2 does not emit them natively. Dashboards and the audit side live in CloudWatch & CloudTrail Observability.

Architecture at a glance

Read the diagram left to right, the way a request travels. A user resolves the domain through Route 53 and lands on CloudFront, which serves /static/* straight from a private S3 bucket via OAC while AWS WAF managed rules drop injection attempts and known-bad clients at the edge — only clean, dynamic requests ever enter your VPC. They arrive at the ALB in the public subnets, which terminates TLS on 443 and forwards to whichever web-tier instances are passing their /healthz probes, spread across two AZs by the Auto Scaling group.

The path then repeats one tier down: web instances call the internal ALB on 8080 fronting the app-tier ASG, and app instances talk SQL on 3306 to RDS Multi-AZ, whose synchronous standby waits in the second AZ for a failover that flips DNS in about a minute. The five numbered badges mark the decisions that make this drawing production-grade rather than decorative: edge filtering, ALB health checks, target-tracking scale-out, the security-group chain, and Multi-AZ failover.

AWS three-tier architecture: Route 53 and CloudFront+WAF at the edge serving static assets from S3 via OAC; dynamic requests hit a public ALB across two AZs, a web-tier ASG (t3.small ×2–6), an internal ALB on 8080, an app-tier ASG (t3.medium ×2–6), then SQL on 3306 to RDS MySQL Multi-AZ — five numbered badges mark the availability and security decision points.

What fails when: the resilience ledger

This table is the point of the whole architecture. Before building it you should be able to state what each failure does to users — afterwards, test the first three rows deliberately.

Failure event What the platform does User impact Recovery Your action
Web/app instance dies or hangs ALB pulls it after 2 failed checks (~20–30 s); ASG replaces it A few in-flight requests error; retries succeed InService in ~2–5 min None; find the cause later
Entire AZ goes down Surviving-AZ ALB nodes keep serving; ASG rebuilds capacity there; per-AZ NAT keeps egress Brief blip; ~50% capacity until refill — the 50% CPU target absorbs it Minutes None immediately; watch CPU alarms
RDS primary fails (or its AZ dies) Automatic failover: standby promoted, endpoint DNS flipped Writes fail ~60–120 s; pools reconnect 1–2 min None; confirm via RDS event
NAT gateway’s AZ lost Per-AZ NAT: surviving AZ unaffected. Single NAT: surviving AZ loses outbound internet (inbound still works) None / third-party-call features fail — / until routes re-pointed Nothing / add a NAT in the healthy AZ
Bad data written (bug, human error) Nothing — Multi-AZ faithfully replicates the mistake Wrong data served PITR restore to a new instance The one row where you recover — rehearse it
Whole region down Nothing in this design Full outage DR territory See AWS Backup & Disaster Recovery Strategies

Real-world scenario

TicketNila, a Chennai event-ticketing startup, ran exactly the “everything box” this article opens with: one m5.xlarge with nginx, Django and MySQL serving ~40 requests/second. Then a film-launch on-sale was signed, projected at 900 rps for two hours on a Friday evening.

The rebuild followed this article’s shape: a /16 VPC across ap-south-1a/1b, ALB, web tier (t3.small, 2–12), app tier (t3.medium, 2–10), MySQL to db.r6g.large Multi-AZ, images to S3 behind CloudFront. Load-testing at 600 rps found the first landmine: Django sessions were in local memory, so as the fleet scaled 2→8, users bounced between instances and were logged out mid-purchase; ALB sticky sessions went in as a Friday-safe band-aid. The second was quieter: the ASG used health-check-type EC2, so an instance with wedged gunicorn workers (healthy kernel) sat in rotation serving 502s until a human noticed. Switching to ELB checks with a 120 s grace period made the fleet actually self-healing.

On-sale night peaked at 870 rps: web tier 2→9, app tier 2→7, CPU near the 50% target, p95 under 400 ms. At 19:41 — mid-peak — the RDS primary’s host failed. Failover took 94 seconds; 61 checkout requests errored, every one succeeded on retry, and nobody outside the ops channel knew. The follow-ups were the real fixes: sessions to ElastiCache Redis (stickiness off), a read replica for the analytics dashboard that had been stealing primary IOPS, scheduled scaling to min 6 before any announced on-sale. The bill went from ₹9,000 to ~₹52,000/mo — “the cheapest insurance in the company”, per the CTO, after seeing the failover graph.

Advantages and disadvantages

Advantages Disadvantages
Survives instance loss, AZ loss and DB failure with no operator action 24×7 baseline cost (~$250/mo small) even at zero traffic — serverless idles cheaper
Every component scales independently; capacity follows load You manage OS patching, AMIs and runtimes on every instance
Clear security story: SG chain, private compute, no-egress data subnets More moving parts than PaaS — needs IaC discipline
No vendor magic: portable, transparent, easy to reason about Scale-out reacts in minutes, not milliseconds — spikes need pre-warming
Deployments are rolling and reversible via instance refresh Relational tier still scales up, not out (until Aurora/replicas enter)
Maps directly to SAA exam scenarios and enterprise review checklists Overkill for a blog, brochure site or true MVP

The disadvantages share one theme: you are operating servers. A fair trade when you need runtime control, predictable latency and steady traffic that makes per-request pricing expensive — a bad trade for tiny or wildly bursty workloads, which is what the next section is for.

Alternatives: when not to build this

If this describes you Use instead You give up You gain
Learning, prototypes, modest traffic Lightsail (fixed-price bundles, managed-ish DB and LB) Fine-grained VPC control, ASG elasticity ~$10–40/mo predictable bill, hours less setup
This exact architecture, but managed Elastic Beanstalk (builds ALB+ASG+RDS for you) Some knob access — still understand what it built Deploys, health, scaling wired in a day
Team ships containers already ECS on Fargate behind the same ALB Per-instance OS control (good riddance) No instances to patch — see Your First Container Deployment on ECS Fargate
Spiky/low traffic, event-driven Serverless: API Gateway + Lambda + DynamoDB Long-running processes, warm state; cold starts appear Zero idle cost — see AWS Lambda Event-Driven Patterns
Actually a static site + API S3 + CloudFront front end Server-rendered pages Pennies per month — see Static Websites on S3 + CloudFront

The honest heuristic: three-tier-on-EC2 earns its keep with steady traffic, OS-level control needs, or software that expects long-lived servers. When none hold, move up an abstraction level — the head-to-head is in AWS Compute: EC2 vs Lambda vs ECS vs EKS.

Hands-on lab

Build a minimum honest version — one compute tier (web), the ALB, a database — in about an hour. Cost note: use db.t4g.micro single-AZ for the lab (Multi-AZ doubles cost and can be taken on faith until production), and tear down the NAT gateway and ALB promptly — they bill hourly.

  1. Network. Run the VPC/subnet/IGW/NAT commands from the network section (or the Terraform module) in ap-south-1: two public, two private subnets; the lab DB shares the private ones.
  2. Security groups. alb-sg (443/80 from 0.0.0.0/0), web-sg (80 from alb-sg), db-sg (3306 from web-sg) — the chain, minus the middle tier.
  3. Instance role. AmazonSSMManagedInstanceCore plus an instance profile.
  4. Launch template. Amazon Linux 2023, t3.micro, web-sg, the profile, HttpTokens=required, and user data that installs nginx and writes /healthz:
#!/bin/bash
dnf install -y nginx
echo ok > /usr/share/nginx/html/healthz
echo "Served by $(hostname -f)" > /usr/share/nginx/html/index.html
systemctl enable --now nginx
  1. Target group + ALB + listener. The three elbv2 commands from the ALB section (an HTTP-only listener on 80 is fine without a domain — skip ACM/443).
  2. ASG. Min 2 / max 4 across the private subnets, attached to the target group, --health-check-type ELB --health-check-grace-period 120, plus the CPU-50 target-tracking policy.
  3. Database. aws rds create-db-subnet-group over the private subnets, then create-db-instance as in the data-tier section but with --db-instance-class db.t4g.micro --no-multi-az --allocated-storage 20 and --vpc-security-group-ids sg-db.
  4. Verify. curl http://<alb-dns-name>/ repeatedly — the served hostname alternates between two instances; the target group shows 2 healthy targets.
  5. Break it (the whole point). aws ec2 terminate-instances on one instance. Keep curling: traffic never stops, and within ~3 minutes the ASG shows a fresh InService replacement. If you built Multi-AZ, rehearse a failover — aws rds reboot-db-instance --db-instance-identifier app-db --force-failover — and time it.
  6. Teardown, in dependency order — ASG (delete-auto-scaling-group --force-delete), ALB + target group, RDS (delete-db-instance --skip-final-snapshot), NAT gateway, released EIP, subnets, IGW, VPC. The NAT and ALB keep billing if forgotten.

Common mistakes & troubleshooting

# Symptom Root cause Confirm Fix
1 All targets “unhealthy”, site 503s, instances fine web-sg doesn’t allow the health-check port from the ALB’s SG Target group health reason: Request timed out Inbound rule on web-sg, source alb-sg
2 ASG launches → terminates → launches, forever Grace period shorter than app boot Activity history: “ELB health check failure” seconds after launch Raise --health-check-grace-period; make /healthz exist early
3 Instances can’t dnf install / reach APIs No 0.0.0.0/0 → NAT route, or NAT in a private subnet describe-route-tables; SSM in, curl -m5 https://aws.amazon.com Add the NAT route per AZ; NAT belongs in a public subnet
4 App can’t reach RDS: connection timed out db-sg references wrong SG, or wrong subnet group From app instance: nc -zv <db-endpoint> 3306 db-sg source = app-sg; subnet group over data subnets
5 Users randomly logged out when fleet scales Session state in instance memory Bug reports correlate with ASG scale events Sessions → ElastiCache/DynamoDB; stickiness only as a bridge
6 502s from ALB, instances look healthy App keep-alive shorter than ALB idle timeout (60 s) ALB access logs: elb 502, target closed connection App keep-alive > 60 s (e.g. 65–75 s)
7 Whole fleet unhealthy during DB failover /healthz does a database query All targets fail the second the RDS failover fires Shallow /healthz; alarm DB reachability separately

Best practices

Security notes

The perimeter is deliberately tiny: one ALB listener on 443 (plus a redirect on 80); everything else has no public IP and no internet route to it. Keep it that way: WAF on CloudFront or the ALB; alb-sg restricted to the CloudFront origin-facing prefix list; TLS 1.2+ policies with auto-renewing ACM certificates. Encrypt at rest everywhere it is a checkbox — EBS, RDS storage (StorageEncrypted: true, settable only at creation), S3 default encryption — and re-encrypt ALB→instance if compliance requires end-to-end TLS. Identity-wise: per-tier least-privilege IAM roles, the DB credential in Secrets Manager, IMDSv2 required, and CloudTrail + VPC Flow Logs on so both API-level and network-level history exist when you must answer “what talked to what, when”.

Cost & sizing

Approximate on-demand pricing for the small deployment (us-east-1 rates, ₹ at ~84/USD — ballpark; check the calculator for Mumbai):

Component Sizing ~USD/mo ~INR/mo
Web tier EC2 2 × t3.small $30 ₹2,550
App tier EC2 2 × t3.small $30 ₹2,550
EBS 4 × 20 GB gp3 $6 ₹550
Public ALB 1 + light LCU $20 ₹1,700
Internal ALB 1 + light LCU $18 ₹1,500
NAT gateway 1 × ($33 + ~100 GB) $37 ₹3,100
RDS MySQL db.t4g.medium Multi-AZ, 50 GB gp3 ×2 $105 ₹8,800
S3 + CloudFront 20 GB assets, ~50 GB egress (free tier) $2 ₹170
Route 53 1 zone + queries $1 ₹85
CloudWatch ~10 alarms, 5 GB logs $6 ₹500
Total ~$255 ~₹21,500

What the bill teaches: the database is ~40% — Multi-AZ doubles instance cost, which is why staging runs single-AZ; NAT is the sneaky one — a second NAT adds ~$33/mo before per-GB processing, which the free S3 gateway endpoint exists to dodge; and compute is the flexible part — Graviton (t4g) shaves ~20%, a 1-year Compute Savings Plan ~28–40% on the steady minimums, and max-size instances bill only while scaling runs. A dev environment of the same shape — single AZ, one NAT, no internal ALB, single-AZ db.t4g.micro, min 1 — lands near $70–90/mo (~₹6,000–7,500).

Interview & exam questions

  1. Why split an application into three tiers? Independent failure domains, scaling behaviour and security boundaries per layer — a crash or compromise in one tier is contained by network design, not luck.

  2. Why must web/app instances be stateless? So Auto Scaling can kill or replace any instance at any time with zero data loss: sessions to ElastiCache/DynamoDB, files to S3, data to RDS. Disposability is what self-healing depends on.

  3. What lives in public subnets here? Only the ALB and NAT gateways. Instances and the database sit in private subnets — the SAA exam loves “EC2 in public subnets” as a tempting wrong answer.

  4. How does RDS Multi-AZ failover work, and how long? The synchronous standby is promoted and RDS flips the DNS behind the endpoint name; clients reconnect — typically 60–120 s (~35 s for Multi-AZ DB clusters). No data loss: replication is synchronous.

  5. Multi-AZ vs read replica? Multi-AZ is availability: synchronous, not readable, same endpoint, automatic failover. Replicas are scale: asynchronous, readable, separate endpoints, manual promotion. Neither replaces backups, which protect against bad data.

  6. What does the ASG’s ELB health-check type change? The ASG replaces instances the load balancer deems unhealthy, not just EC2 hardware-check failures — otherwise a dead app on a healthy kernel stays in the group serving errors.

  7. How do the security groups chain, and why SG references over CIDRs? alb-sg ← internet; web-sg ← alb-sg; app-sg ← int-alb-sg; db-sg ← app-sg. SG references track membership as the fleet scales; CIDR rules go stale and drift permissive.

  8. Why one NAT gateway per AZ? A NAT gateway is HA within its AZ only: with a single NAT, the surviving AZ loses outbound internet when the NAT’s AZ dies — and cross-AZ data charges apply besides.

  9. A scaling event logs users out. Diagnose. Session state on instances. Mitigate with ALB sticky sessions; fix by externalising sessions, then disable stickiness.

  10. When would you recommend against this architecture? Very low or wildly spiky traffic (serverless), minimal-ops teams (Lightsail/Beanstalk), static sites (S3+CloudFront), container-native teams (ECS/Fargate). It pays off with steady traffic and OS-level control needs.

Quick check

  1. Which two resource types belong in the public subnets?
  2. An instance’s app hangs but the kernel is healthy. Which ASG setting gets it replaced?
  3. During an RDS Multi-AZ failover, what changes: the endpoint name, or the IP behind it?
  4. Web instances must accept port 80 only from the ALB. What is the SG rule’s source?
  5. Which components bill hourly even at zero traffic?

Answers

  1. The ALB and the NAT gateways — never application instances or the database.
  2. --health-check-type ELB (with an adequate grace period), so ALB-detected failures trigger replacement.
  3. The IP behind it. The endpoint name is stable — exactly why applications must connect by name.
  4. The ALB’s security group ID (alb-sg), not a CIDR block.
  5. The ALB(s), the NAT gateway(s) and RDS — none of them scale to zero.

Glossary

Next steps

AWSThree-Tier ArchitectureVPCALBAuto ScalingRDS Multi-AZHigh AvailabilityCloudFront
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading