A web application can begin on a single EC2 instance. At the start of a product, that is often a sensible trade-off: lower cost, less operational overhead, and a shorter path to production. It stops being enough when the application must remain available through a deployment, an infrastructure failure, or a traffic increase.
Moving to a three-tier design is not about adding AWS services to a diagram. It is about separating responsibilities and making each network path explicit: users reach an entry point, the entry point reaches the application, and only the application reaches the database.

This article uses a deliberately familiar scenario: an HTTP application, an EC2 backend, and PostgreSQL on RDS. The infrastructure is managed with Terraform. EC2 is not presented as the best option in every context; it is useful here because routing, health checks, load balancing, Auto Scaling, and failover remain easy to see. A containerized workload may be better served by ECS or EKS.
The network sets the boundaries of the architecture
The VPC uses 10.0.0.0/16. Within it, I reserve a public subnet, an application subnet, and a data subnet in each Availability Zone.

There is nothing special about these ranges. Their value is that they remain easy to read. An address such as 10.0.12.x immediately identifies an application resource in the second zone, which is useful when reviewing Flow Logs or investigating an incident months after deployment.
The /16 also leaves space for future environments, hybrid connectivity, or a Kubernetes cluster. CIDR planning deserves attention early: it is much harder to correct than an AMI or an instance type.
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_support = true
enable_dns_hostnames = true
tags = {
Name = "production-vpc"
Environment = "production"
ManagedBy = "terraform"
}
}
A /24 provides 251 usable IPv4 addresses after AWS reservations. That is more than enough for this example. It needs to be revisited for EKS, ECS, or Lambda in a VPC, where network interfaces can consume addresses much faster than the number of visible workloads suggests.
Each subnet belongs to a single Availability Zone. High availability is therefore prepared in the network before it is handed to an Auto Scaling Group.
Routing determines what is public
A subnet name does not grant it access. Its route table determines how it behaves.
Public
10.0.0.0/16 -> local
0.0.0.0/0 -> Internet Gateway
Private with Internet egress
10.0.0.0/16 -> local
0.0.0.0/0 -> NAT Gateway
Isolated
10.0.0.0/16 -> local
Public subnets host the Application Load Balancer and, in a zonal design, NAT Gateways. EC2 instances live in application subnets. They can reach a package repository or an external API without being reachable from the Internet. Database subnets have no default route to the Internet.
resource "aws_route" "public_internet" {
route_table_id = aws_route_table.public.id
destination_cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.main.id
}
Longest prefix match explains how packets move. With a local route for 10.0.0.0/16 and a default route for 0.0.0.0/0 through a NAT Gateway, traffic for 10.0.21.40 stays inside the VPC. Traffic for 8.8.8.8 uses the default route. This rule accounts for a large share of routing issues seen in practice.

One public entry point, private instances
The Application Load Balancer is the only internet-facing application component. It receives HTTPS traffic and distributes it to healthy instances across both zones.
Internet -> Application Load Balancer -> private EC2 instance
Instances do not have public IPv4 addresses. This removes unnecessary attack surface and creates a single, observable, and controllable entry path. Administrative access can go through Systems Manager rather than an SSH port exposed to the Internet.
Instances still need outbound access. A zonal NAT Gateway in each Availability Zone keeps the workload and its egress path aligned. AWS also offers regional NAT Gateways, which can simplify multi-AZ deployments. They do not address private-connectivity requirements, so the model should be selected from the actual traffic flows rather than a general rule.

Not every AWS service needs to be reached through NAT. A Gateway VPC Endpoint for S3 keeps that traffic on the AWS network and avoids paying for NAT egress simply to reach another AWS service.
Security Groups express connectivity intent
The security policy can be reduced to three paths:
Internet -> ALB:443
ALB -> EC2:8080
EC2 -> RDS:5432
The application accepts 8080 only from the ALB Security Group. RDS accepts 5432 only from the application Security Group. Security Group references are stronger than broad CIDR ranges because they follow the real dependencies between components.

resource "aws_security_group" "application" {
name = "production-app-sg"
vpc_id = aws_vpc.main.id
ingress {
description = "Application traffic from ALB"
protocol = "tcp"
from_port = 8080
to_port = 8080
security_groups = [aws_security_group.alb.id]
}
}
Security Groups are stateful, so response traffic does not need a separate return rule. The denied paths should be tested as carefully as the allowed ones: Internet to EC2 and Internet to RDS must fail.
A replaceable fleet, not servers to repair
The ALB does not care whether an instance is merely running. Its health check measures the application through an endpoint such as /health. A machine can be powered on while Nginx, the runtime, or the application is no longer responding.
The Auto Scaling Group maintains a minimum capacity of two instances. When a target becomes unhealthy, the ALB stops sending requests to it. The group then creates another instance to restore the expected capacity. That only works when an instance is truly replaceable: no user files, critical sessions, or manually changed configuration should remain on it.
A useful test is to stop Nginx without stopping EC2. If /health fails, the target should leave the load balancer and be replaced. That demonstrates recovery better than a screenshot of two healthy instances.
RDS Multi-AZ improves availability, not read throughput
RDS Multi-AZ maintains a synchronous standby in another Availability Zone. It improves availability and enables failover, but the standby does not serve application reads. Read replicas or a Multi-AZ DB cluster are the appropriate options to consider when read scaling is the requirement.
The database remains in dedicated subnets with no public exposure. Secrets should not be scattered across application code or broadly handled Terraform variables. When it fits the use case, letting RDS manage the master password in Secrets Manager reduces the risk of exposing it in Terraform state.
Multi-AZ is not a backup strategy. A data error is replicated just as efficiently as a valid write. Automated backups, point-in-time recovery, and tested restores are still required.
Terraform, review, and operations
Terraform makes the infrastructure reproducible and brings changes into code review. It does not make a change safe by itself. terraform plan remains a key step because it reveals deletions, route changes, new network exposure, and RDS updates that could trigger replacement.

In production, state should be remote, encrypted, and tightly access-controlled. Deployments should run through CI/CD using OIDC rather than long-lived access keys.
Validate the expected behavior, including failure
The application must return a response, but prohibited paths must remain unavailable:
Internet -> ALB:443 : allowed
ALB -> EC2:8080 : allowed
EC2 -> RDS:5432 : allowed
Internet -> EC2:8080 : denied
Internet -> RDS:5432 : denied
Reachability Analyzer and VPC Flow Logs help verify that intent. I then test instance replacement, behavior under load, the time needed for a new target to become healthy, and application recovery during an RDS failover.
The useful question is not only, “Is the system highly available?” It is, “When a component fails, what does the user experience, and for how long?”
Conclusion
An AWS three-tier architecture is judged less by its diagram than by its behavior under stress. Network segmentation, Security Groups, Auto Scaling, and RDS Multi-AZ matter only when they produce predictable service behavior after a component fails.
This design is not a universal recipe. A small internal application does not have the same requirements as a transactional platform. The right trade-offs always depend on accepted risk and the real cost of downtime.


Discussion
Comments
Readers can react, ask a question or continue the conversation. A GitHub sign-in is required before posting a comment.