Skip to content
AWS aws architecture 6 min read

A Classic Three-Tier Web App

The three-tier architecture is the most common way to build a traditional web application, and it is the design AWS interviews and the Well-Architected Framework expect you to know cold. You split your system into three layers: a web tier (handles incoming traffic), an app tier (runs your business logic), and a data tier (stores your data). Keeping these layers separate makes your app easier to scale, secure, and reason about. This page shows you exactly how to lay it out on AWS, where each piece belongs, and the one security mistake almost everyone makes.

What the three tiers actually are

Each tier has one job, and the layers talk to each other in a strict order: traffic flows web → app → data, never skipping or reversing.

TierWhat it doesAWS servicesWhere it lives
Web (presentation)Receives user requests, serves the UI, balances loadApplication Load Balancer (ALB), EC2 in an Auto Scaling Group (ASG)Public subnet (ALB only) + private subnet (servers)
App (logic)Runs business logic, talks to the databaseEC2 in an ASG, or containers (ECS)Private subnet
Data (persistence)Stores and retrieves data durablyAmazon RDS (a managed relational database) Multi-AZIsolated private subnet

A few terms defined up front:

  • VPC (Virtual Private Cloud): your own private, isolated network inside AWS.
  • Subnet: a slice of that network. A public subnet can reach the internet; a private subnet cannot be reached from the internet directly.
  • AZ (Availability Zone): a physically separate data center. Spreading across AZs survives the loss of one.
  • Security Group (SG): a virtual firewall attached to a resource that controls what traffic is allowed in and out.

The canonical layout

Picture a VPC spread across two Availability Zones (us-east-1a and us-east-1b) for high availability:

  • One public subnet per AZ — holds only the ALB (the load balancer). The ALB is the only thing facing the internet.
  • One private “app” subnet per AZ — holds your web/app EC2 instances inside an Auto Scaling Group.
  • One isolated private “data” subnet per AZ — holds your RDS database (primary in one AZ, standby in the other).

The ALB receives traffic on port 443, forwards it to the app instances on port 8080, and the app instances connect to RDS on port 3306 (MySQL) or 5432 (PostgreSQL). The standby RDS instance is a hot copy that AWS promotes automatically if the primary fails — this is what “Multi-AZ” means.

The #1 three-tier mistake: putting the app tier or the database in a public subnet. Only the load balancer should be internet-facing. Your app servers and database belong in private subnets, and their security groups should reference each other — not open CIDR ranges like 0.0.0.0/0. If your database has a public IP, you have built a data breach waiting to happen.

Chaining security groups tier to tier

This is the heart of a secure three-tier design. Instead of allowing traffic from IP ranges, each tier’s security group allows traffic only from the security group of the tier directly in front of it. This is called referencing a security group as a source.

  • ALB SG — allows inbound 443 from the internet (0.0.0.0/0). This is fine; the ALB is meant to be public.
  • App SG — allows inbound 8080 only from the ALB SG. No internet access.
  • Data SG — allows inbound 3306/5432 only from the App SG. The database is reachable by nothing else.

So even if someone discovered your database’s private address, no security group permits a connection from anywhere except your app servers.

Create the chained security groups (CLI)

# 1. App tier accepts traffic only from the ALB's security group
aws ec2 authorize-security-group-ingress \
  --group-id sg-0a1b2c3dapp00001 \
  --protocol tcp --port 8080 \
  --source-group sg-0a1b2c3dalb00001

# 2. Data tier accepts traffic only from the App's security group
aws ec2 authorize-security-group-ingress \
  --group-id sg-0a1b2c3ddb000001 \
  --protocol tcp --port 5432 \
  --source-group sg-0a1b2c3dapp00001

Output:

{
    "Return": true,
    "SecurityGroupRules": [
        {
            "SecurityGroupRuleId": "sgr-0f9e8d7c6b5a4321",
            "GroupId": "sg-0a1b2c3ddb000001",
            "IsEgress": false,
            "IpProtocol": "tcp",
            "FromPort": 5432,
            "ToPort": 5432,
            "ReferencedGroupInfo": { "GroupId": "sg-0a1b2c3dapp00001" }
        }
    ]
}

Notice there is no CidrIpv4 in the rule — it points at a ReferencedGroupInfo. That is the secure pattern.

Building the web/app tier

The app tier runs on EC2 instances managed by an Auto Scaling Group, which automatically adds or removes instances based on load and replaces any that fail. The ALB sits in front and spreads requests across them.

When to use this: any stateful or long-running web application where you control the runtime (a Spring Boot, Django, or Node.js app). When not to: if your traffic is spiky and event-driven, a serverless approach (Lambda + API Gateway) may be cheaper and simpler — see the serverless link below.

Console steps

  1. Open the EC2 console → Load BalancersCreate load balancerApplication Load Balancer.
  2. Set scheme to Internet-facing, choose your VPC, and select both public subnets.
  3. Assign the ALB SG (sg-0a1b2c3dalb00001) and add an HTTPS :443 listener.
  4. Go to Auto Scaling GroupsCreate Auto Scaling group. Pick a launch template that uses your app AMI (ami-0abcdef1234567890) and the App SG.
  5. For network, select both private app subnets (never the public ones).
  6. Attach the ASG to the ALB’s target group, set min 2, desired 2, max 6.

CLI equivalent

aws autoscaling create-auto-scaling-group \
  --auto-scaling-group-name app-tier-asg \
  --launch-template "LaunchTemplateId=lt-0a1b2c3d4e5f6789,Version=1" \
  --min-size 2 --max-size 6 --desired-capacity 2 \
  --vpc-zone-identifier "subnet-0a1b2c3dapp1a,subnet-0a1b2c3dapp1b" \
  --target-group-arns "arn:aws:elasticloadbalancing:us-east-1:111122223333:targetgroup/app-tg/0a1b2c3d4e5f6789"

Minimum of 2 instances across two subnets means each AZ holds one — losing an AZ never takes you to zero.

Building the data tier

Use Amazon RDS in Multi-AZ mode placed in an isolated private subnet group. RDS handles backups, patching, and automatic failover for you.

aws rds create-db-instance \
  --db-instance-identifier prod-app-db \
  --engine postgres --db-instance-class db.r6g.large \
  --allocated-storage 100 --multi-az \
  --db-subnet-group-name data-tier-subnets \
  --vpc-security-group-ids sg-0a1b2c3ddb000001 \
  --master-username appadmin --manage-master-user-password

Output:

{
    "DBInstance": {
        "DBInstanceIdentifier": "prod-app-db",
        "DBInstanceStatus": "creating",
        "MultiAZ": true,
        "PubliclyAccessible": false,
        "Engine": "postgres"
    }
}

Confirm "PubliclyAccessible": false every time. Use --manage-master-user-password so the password is generated and stored in AWS Secrets Manager rather than passed in plain text.

Cost note: Multi-AZ roughly doubles the database bill (you pay for the standby), and a db.r6g.large runs about $0.48/hour (~$350/month) plus storage. For non-production environments, use Single-AZ to cut that in half. Two NAT Gateways (one per AZ, so private instances can reach the internet for updates) add about $65/month — a real and often-forgotten line item.

Best Practices

  • Make only the ALB internet-facing; keep the app and data tiers in private subnets always.
  • Chain security groups by referencing the upstream SG as the source — never use open CIDRs for app or database traffic.
  • Spread every tier across at least two AZs and run RDS in Multi-AZ for automatic failover.
  • Never give RDS a public IP; verify PubliclyAccessible is false.
  • Store database credentials in Secrets Manager, not in environment variables or launch templates.
  • Let the Auto Scaling Group keep a minimum of two healthy instances so a single AZ or instance failure is invisible to users.
  • Put a NAT Gateway per AZ so private-tier patching survives the loss of one zone.
Last updated June 15, 2026
Was this helpful?