open atlas
↑ Back to track
AWS, hands-on AWS · 08 · 01

Capstone: a secure, resilient three-tier architecture end to end

The capstone: wire storage, networking, compute, data, security, observability, IaC, and cost into one secure, resilient three-tier web app — Route 53 to CloudFront to ALB to Fargate to RDS Multi-AZ and S3 — and trace the request path plus every trust boundary it crosses.

AWS Senior ◷ 22 min
Level
FoundationsJuniorMiddleSenior

A startup called its design “three-tier” and shipped it. ALB in front, app servers behind, a Postgres database in back — textbook. Then the auditor asked one question: which subnet is the database in? Nobody knew. They looked. The RDS instance had been launched into a public subnet with a security group that allowed 5432 from 0.0.0.0/0, because someone had needed to connect from a laptop “just for the migration” eighteen months ago and never undid it. The database had a public IP and an open port to the entire internet for a year and a half. It had not been breached yet — that was luck, not architecture. The same review found a second, quieter fault: the whole stack lived in a single Availability Zone, so the “highly available” app would die the moment that one AZ had a bad day. “Three-tier” is not a diagram with three boxes. It is a set of trust boundaries and failure boundaries, and if you cannot name where each one sits, you do not have them.

By the end of this lesson you will be able to walk the full request path, name every trust boundary along it, and spot the two structural gaps that made the startup’s “three-tier” an incident waiting to happen.

The shape: one VPC, two AZs, three tiers

Everything lives in one VPC spanning at least two Availability Zones — that “at least two” is the whole reliability story, because an AZ is the blast radius AWS lets fail independently. Inside the VPC, subnets split into two trust zones. Public subnets (one per AZ) hold only the internet-facing edge: the Application Load Balancer and the NAT Gateways. Private subnets hold everything that should never be directly reachable: the Fargate app tasks and the RDS database. The rule is brutally simple — if a resource has a route to an internet gateway, it is exposed; the database must not.

The request path tells the story. A user hits your apex domain; Route 53 resolves it (an ALIAS record at the zone apex, which is how you point a bare domain at an AWS resource since you cannot CNAME an apex). The request lands on CloudFront, which terminates TLS at the edge using an ACM certificate, serves cached static assets, and forwards dynamic requests to the Application Load Balancer. The ALB lives in the public subnets, listens on HTTPS, and routes to a target group of Fargate tasks in the private subnets. A task runs your app, and to do its job it talks to two backends: RDS (Postgres, Multi-AZ) for relational data and S3 for objects. The reply walks back out the same chain. Every arrow in that path is also a boundary you get to lock down.

Each tier maps to a managed service for a reason — you are not running the muscle, AWS is, and you spend your attention on the boundaries instead.

TierServiceWhy this one
Edge / DNSRoute 53 + CloudFront + ACMALIAS apex to an AWS target; TLS terminated at the edge; static assets cached off the origin
Load balancingApplication Load BalancerLayer-7 HTTPS, health checks, the one public ingress to the app; spreads traffic across AZs
App (compute)ECS on FargateServerless containers in private subnets, no nodes to patch, autoscale on load, task-role IAM
Data (relational)RDS Postgres, Multi-AZSynchronous standby in a second AZ, automatic failover; reachable only from the app SG
Data (objects)S3 + VPC gateway endpointDurable object store; private path via gateway endpoint; presigned URLs or CloudFront OAC for delivery
EgressNAT Gateway (per AZ)Lets private tasks reach the internet to pull images/patches without being reachable inbound

Security: boundaries that reference each other

The security model is defense in depth, the spine of the Well-Architected Security Pillar: many layers, each useless to bypass alone. The first layer is the subnet split — RDS in a private subnet simply has no route to the internet, so “open port to the world” stops being possible. The second is security groups that reference each other by ID, not by CIDR. You write three: the ALB SG allows 443 from the internet; the app SG allows traffic only from the ALB SG; the DB SG allows 5432 only from the app SG. Nobody types an IP range. The chain is internet to ALB SG to app SG to DB SG, and each link is least privilege by construction — exactly the failure the startup hit, except now structurally impossible.

The rest of the layers stack on top. Secrets (the DB password, API keys) live in Secrets Manager and are injected into the task at runtime, never baked into an image or an env file in git. Encryption at rest comes from KMS on RDS and S3; encryption in transit is TLS end to end — ACM at CloudFront, and the app-to-RDS hop using the RDS CA. The Fargate task role is a least-privilege IAM role that grants exactly the S3 bucket and Secrets Manager paths this service needs and nothing more, so a compromised container cannot pivot across the account. Nothing in the entire system is publicly reachable except CloudFront and the ALB.

# Trimmed CloudFormation: the security boundaries are the whole point.
Resources:
  AlbSG:
    Type: AWS::EC2::SecurityGroup
    Properties:
      VpcId: !Ref Vpc
      SecurityGroupIngress:
        - { IpProtocol: tcp, FromPort: 443, ToPort: 443, CidrIp: 0.0.0.0/0 }  # ALB = only public ingress

  AppSG:                       # Fargate tasks — private subnets only
    Type: AWS::EC2::SecurityGroup
    Properties:
      VpcId: !Ref Vpc
      SecurityGroupIngress:
        - { IpProtocol: tcp, FromPort: 8080, ToPort: 8080, SourceSecurityGroupId: !Ref AlbSG }

  DbSG:                        # RDS — reachable by the app tier and nothing else
    Type: AWS::EC2::SecurityGroup
    Properties:
      VpcId: !Ref Vpc
      SecurityGroupIngress:
        - { IpProtocol: tcp, FromPort: 5432, ToPort: 5432, SourceSecurityGroupId: !Ref AppSG }

  Db:
    Type: AWS::RDS::DBInstance
    Properties:
      Engine: postgres
      MultiAZ: true                          # synchronous standby in a 2nd AZ, auto failover
      StorageEncrypted: true                 # KMS at rest
      PubliclyAccessible: false              # never a public IP
      DBSubnetGroupName: !Ref PrivateDbSubnets
      VPCSecurityGroups: [ !Ref DbSG ]
      ManageMasterUserPassword: true         # password lives in Secrets Manager, not the template

This is infrastructure as code on purpose. The whole VPC, every subnet, every SG rule, the ALB, the Fargate service, and the RDS instance are declared in one reviewable template (CloudFormation, CDK, or Terraform). The open-DB incident happened because a human ran a one-off console change that nobody reviewed and nobody could see later. When the architecture is code, that drift shows up in a diff and gets caught in review — the SG rule above cannot silently become 0.0.0.0/0 without someone approving the pull request.

Resilience and observability: surviving an AZ and seeing it happen

The single-AZ fault from the hook is fixed structurally. The ALB spans both public subnets; Fargate tasks run across both private subnets; and RDS Multi-AZ keeps a synchronous standby in a second AZ. Per the RDS docs, replication to that standby is synchronous, so a committed write is on both AZs before the client gets an ack, and on primary failure AWS performs an automatic failover by flipping the DB’s DNS endpoint to the standby — your app reconnects to the same hostname. The cost of that safety is real: synchronous replication adds write/commit latency versus single-AZ, and you pay for the standby. That is the central resilience tradeoff, and for a production data tier it is almost always worth paying. (Note the standby does not serve reads — it exists for failover; if you need read scaling you add read replicas or a Multi-AZ cluster.)

You cannot operate what you cannot see, so observability is wired in from the start, the way the Well-Architected detection guidance prescribes. CloudWatch collects metrics and logs from every tier; alarms watch the signals that actually mean “users are hurting”: ALB HTTPCode_ELB_5XX_Count, Fargate CPU/memory utilization (which also drives autoscaling), and RDS DatabaseConnections and free storage. Alarms fire to an SNS topic that pages a human. X-Ray traces a request across CloudFront to ALB to Fargate to RDS so that when latency spikes you can see which tier owns it instead of guessing. The point of tracing the request path earlier is exactly this: each hop is a place a metric, a log, and a trace segment must exist.

Why this works

Why per-AZ NAT Gateways instead of one shared one? A NAT Gateway lives in a single AZ. If you route both private subnets through one NAT in AZ-A and AZ-A fails, your AZ-B tasks lose internet egress even though their own AZ is healthy — you have reintroduced a single point of failure for the sake of saving money. One NAT per AZ removes that coupling. The tradeoff is cost: NAT Gateways bill per hour and per GB processed, so two of them plus egress data is a line item people genuinely forget until the bill arrives. For traffic that only talks to AWS services (S3, DynamoDB), a VPC gateway endpoint routes around NAT entirely and is free — which is why the S3 path in this design uses one.

Cost: the same design, priced

A correct architecture you cannot afford gets rewritten under pressure into an incorrect one, so cost is part of the design, not an afterthought. The Fargate app tier runs a steady baseline plus autoscaling headroom; you cover the baseline with a Compute Savings Plan (a 1- or 3-year commit that discounts Fargate and Lambda alike) and let on-demand absorb the spikes. The data tier’s Multi-AZ standby roughly doubles RDS compute cost — that is the availability premium, and it is the one place you do not cut. NAT Gateway hours and per-GB egress are the sneaky line item; the per-AZ NATs are for resilience, but you cut their bill by sending AWS-bound traffic (S3) through a free gateway endpoint and keeping chatty cross-AZ traffic in check. S3 gets lifecycle rules to tier cold objects to cheaper storage classes. Right-size Fargate task vCPU/memory and RDS instance class against real CloudWatch utilization rather than guessing — the observability you built for reliability also tells you where you are overprovisioned.

Pick the best fit

A production B2B SaaS runs its primary transactional Postgres on a single-AZ RDS instance to save money. Leadership wants to know whether to switch the data tier to Multi-AZ. Pick the soundest position.

Quiz

In the locked-down design, how should the RDS database's security group be configured to allow the app tier to connect on 5432?

Quiz

The Fargate app tasks live in private subnets and need to pull updates and reach external APIs over the internet, but must not be reachable from the internet inbound. What provides that?

Recall before you leave
  1. 01
    Walk the full request path of this three-tier design from the user to the data tier, naming the service and the boundary at each hop.
  2. 02
    Name the resilience and security boundaries that the failed 'three-tier' app from the hook was missing, and how this design fixes each.
Recap

A real three-tier architecture is defined by its boundaries, not by drawing three boxes. Everything lives in one VPC across at least two Availability Zones, split into public subnets that hold only the internet-facing ALB and the NAT Gateways, and private subnets that hold the Fargate app tasks and the RDS database. The request path runs Route 53 ALIAS apex, then CloudFront with ACM TLS and caching, then the public ALB on HTTPS, then a target group of Fargate tasks in private subnets, then RDS Postgres Multi-AZ and S3 via a VPC gateway endpoint, and every one of those arrows is a trust boundary. Security is defense in depth: security groups that reference each other by ID form the chain internet to ALB SG to app SG to DB SG so the database is reachable by nothing but the app tier; secrets live in Secrets Manager, data is encrypted at rest with KMS and in transit with TLS, the Fargate task role is least-privilege IAM, and nothing is public except CloudFront and the ALB. Resilience is Multi-AZ throughout, with RDS keeping a synchronous standby in a second AZ and failing over automatically by flipping the DB endpoint — at the real cost of added write latency and a paid standby, the one premium worth paying. Observability wires CloudWatch metrics, logs, and alarms (ALB 5xx, Fargate CPU, RDS connections) into SNS paging plus X-Ray tracing across tiers, and the entire stack is infrastructure as code so a drift like an open database security group is caught in a pull-request diff instead of an audit eighteen months later. Cost is designed in too: a Compute Savings Plan on the Fargate baseline, the deliberately-accepted Multi-AZ premium, per-AZ NATs offset by a free S3 gateway endpoint, S3 lifecycle tiering, and right-sizing against the same metrics you collect for reliability. The startup’s stack had three boxes and zero boundaries; the skill is being able to point at each subnet, each security group, and each AZ and say exactly what it protects against. Now when you review any architecture called “three-tier”, ask that question first — and if nobody can answer it, you have found the work to do.

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 5 done

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

Apply this

Put this lesson to work on a real build.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.