AWS ๐Ÿ“– 24 min read

Build a 3-Tier VPC Architecture

In a flat network, where every instance sits in one subnet behind one permissive security group, a single compromised instance can reach everything else, including the database. Splitting the VPC into public, application and data tiers, each with its own route table and security rules, means an attacker has to break through several separate controls to get from the internet to your data. This guide builds that layout by hand in the VPC console so you can see what each piece does. You end up with production-vpc in us-east-1: six subnets across two Availability Zones, an internet-facing Application Load Balancer, two app servers with no public IP, a private PostgreSQL database on RDS, and Flow Logs. It's for readers who have launched instances in the default VPC (see Launch Your First EC2 Instance) and now want to design their own network. To use another Region, swap the Region and AZ names throughout.

What You Need

  • Hardware: Any computer with a web browser and an SSH client
  • Software: An AWS account with an admin identity (see the IAM guide) and an EC2 key pair. This build is not free: NAT gateways, the load balancer, RDS and every public IPv4 address bill by the hour, and the Free Tier (which changed for accounts created on or after July 15, 2025) covers only part of it. Delete it when you're done experimenting. AWS CLI v2 is optional.
  • Time: 1-2 hours

Design goals:

  1. Only the load balancer (and, if you use one, the bastion) is reachable from the internet. App servers and databases have no public IP and no route from the internet gateway.
  2. Each tier only accepts traffic from the tier in front of it: security groups reference each other (ALB โ†’ app โ†’ database) instead of allowing whole CIDR ranges.
  3. The data subnets get a network ACL as a second, stateless layer, so a mistake in one security group doesn't open the database on its own.
  4. VPC Flow Logs record accepted and rejected traffic, so unexpected connections between tiers are visible.

The 3-Tier Architecture

This is the standard pattern for web applications. The diagram uses the exact names and address ranges from the steps below:

Diagram: 3-tier VPC topology for production-vpc (10.0.0.0/16) Traffic from the internet enters through the internet gateway production-igw into production-vpc, 10.0.0.0/16, in us-east-1. The VPC spans two Availability Zones. In us-east-1a: public-subnet-1a (10.0.1.0/24, route table public-rt) holds an ALB node, the NAT gateway nat-gw-1a and the bastion host; private-app-1a (10.0.10.0/24, route table private-rt) holds an app server in app-sg; private-data-1a (10.0.20.0/24, route table private-rt) holds the RDS primary. In us-east-1b: public-subnet-1b (10.0.2.0/24, public-rt) holds the second ALB node and an optional second NAT gateway; private-app-1b (10.0.11.0/24, private-rt) holds an app server; private-data-1b (10.0.21.0/24, private-rt) holds the RDS standby. The ALB forwards to the app servers, the app servers connect to the RDS primary on port 5432, the primary replicates to the standby, and the app servers reach the internet outbound through nat-gw-1a. public-rt sends 0.0.0.0/0 to production-igw; private-rt sends 0.0.0.0/0 to nat-gw-1a; both keep the local route for 10.0.0.0/16. Internet Internet gateway production-igw production-vpc ยท 10.0.0.0/16 ยท Region us-east-1 Availability Zone us-east-1a Availability Zone us-east-1b public-subnet-1a ยท 10.0.1.0/24 Route table: public-rt public-subnet-1b ยท 10.0.2.0/24 Route table: public-rt ALB node production-alb NAT gateway nat-gw-1a Bastion host SSH from your IP ALB node production-alb 2nd NAT gateway optional, production private-app-1a ยท 10.0.10.0/24 Route table: private-rt private-app-1b ยท 10.0.11.0/24 Route table: private-rt App server app-sg ยท HTTP 80 from alb-sg App server app-sg ยท HTTP 80 from alb-sg private-data-1a ยท 10.0.20.0/24 private-rt ยท NACL: 5432 from 10.0.10.0/23 private-data-1b ยท 10.0.21.0/24 private-rt ยท same NACL RDS primary TCP 5432 from app-sg RDS standby Multi-AZ (optional) synchronous replication (RDS-managed) public-rt: 10.0.0.0/16 โ†’ local ยท 0.0.0.0/0 โ†’ production-igw private-rt: 10.0.0.0/16 โ†’ local ยท 0.0.0.0/0 โ†’ nat-gw-1a (plus the S3 gateway endpoint route) Solid arrows: inbound request path. Dashed amber: outbound via NAT. Dotted grey: admin SSH.
Diagram of the VPC built in this guide. Names and CIDRs match the subnet table in Step 2.

Three tiers:

  1. Public tier โ€” Load balancer, NAT gateways, and optionally a bastion. Internet-accessible.
  2. Private app tier โ€” Application servers. No inbound access from the internet; outbound only through the NAT gateway.
  3. Private data tier โ€” Databases. Reachable only from the app tier.

Step 1: Create the VPC

  1. Go to VPC Console
  2. Click Create VPC
  3. Select VPC only (not VPC and more โ€” we'll add subnets manually)
  4. Name: production-vpc
  5. IPv4 CIDR: 10.0.0.0/16 (65,536 IP addresses)
  6. Click Create

A VPC created with VPC only has DNS resolution on and DNS hostnames off. Turn hostnames on: interface VPC endpoints with private DNS (the Session Manager option without a NAT gateway, in Step 8) need both settings.

  1. Select your VPC
  2. Actions โ†’ Edit VPC settings
  3. Enable DNS hostnames
  4. Save

If CIDR notation is new to you, Networking Fundamentals walks through how a prefix like /16 or /24 maps to an address range.

Step 2: Create Subnets

You need six subnets: two public, two private app and two private data, split across two Availability Zones. The Create subnet form has an Add new subnet button, so all six can go in one submission.

Terminal window with the commands from the section Step 2: Create Subnets
List the subnets as a table to check each CIDR block landed in the right availability zone.
  1. VPC โ†’ Subnets โ†’ Create subnet
  2. Select your VPC

Create these subnets:

Name CIDR AZ Type
public-subnet-1a 10.0.1.0/24 us-east-1a Public
public-subnet-1b 10.0.2.0/24 us-east-1b Public
private-app-1a 10.0.10.0/24 us-east-1a Private
private-app-1b 10.0.11.0/24 us-east-1b Private
private-data-1a 10.0.20.0/24 us-east-1a Private
private-data-1b 10.0.21.0/24 us-east-1b Private

The CIDR ranges are just a convention. The key is: don't overlap, and leave room for future expansion. Grouping the two app subnets as 10.0.10.0/24 and 10.0.11.0/24 means the whole app tier is the single block 10.0.10.0/23, which the data-tier network ACL uses later. AWS reserves five addresses in every subnet (the first four and the last), so each /24 has 251 usable addresses (see subnet sizing).

Check the result from the CLI. You should see six rows with the names, CIDRs and AZs from the table:

Bash
aws ec2 describe-subnets --filters Name=vpc-id,Values=vpc-0abc123def456789 \
  --query "Subnets[].[Tags[?Key=='Name'].Value | [0], CidrBlock, AvailabilityZone]" \
  --output table

Leave Auto-assign public IPv4 address off on all six for now.

Step 3: Internet Gateway

An internet gateway attaches to your VPC and routes traffic to and from the public internet. A VPC can have one, and there's no hourly charge for the gateway itself.

  1. VPC โ†’ Internet gateways โ†’ Create internet gateway
  2. Name: production-igw
  3. Create
  4. Select it โ†’ Actions โ†’ Attach to VPC โ†’ Select your VPC

Step 4: Route Tables

Two route tables: public subnets get a default route to the internet gateway, private subnets get a default route to the NAT gateway. Every route table also has a local route for 10.0.0.0/16 that you can't remove; it's what lets subnets in the VPC reach each other. A subnet you don't explicitly associate uses the VPC's main route table, so associate every subnet on purpose.

Public Route Table

  1. VPC โ†’ Route tables โ†’ Create route table
  2. Name: public-rt
  3. VPC: Select your VPC
  4. Create

Add internet route:

  1. Select public-rt โ†’ Routes tab โ†’ Edit routes
  2. Add route: 0.0.0.0/0 โ†’ Target: Internet Gateway โ†’ Select your IGW
  3. Save

Associate with public subnets:

  1. Subnet associations tab โ†’ Edit subnet associations
  2. Select public-subnet-1a and public-subnet-1b
  3. Save

Auto-assign Public IP (Optional)

The load balancer and NAT gateway get their public addresses on their own; this setting only affects EC2 instances launched into the subnet. Turn it on only if you'll launch a bastion here, and remember that every public IPv4 address is billed (see Costs):

  1. Subnets โ†’ Select public-subnet-1a
  2. Actions โ†’ Edit subnet settings
  3. Enable auto-assign public IPv4 address
  4. Save

Step 5: NAT Gateway

Private subnet instances cannot reach the internet directly. They still need outbound access for OS updates and external API calls. A NAT gateway handles this: outbound connections go through it, and replies come back, but nothing can open a connection inward. It's billed per hour while it exists plus per GB processed. The VPC pricing page's US East (Ohio) example uses $0.045 per hour and $0.045 per GB (as listed in October 2026; rates vary by Region), which works out to roughly $33 for a 730-hour month before any traffic. For dev and test, the alternatives are a self-managed NAT instance (you patch and scale it yourself), VPC endpoints for the AWS services you need, or no outbound internet at all.

Terminal window with the commands from the section Step 5: NAT Gateway
Wait for the NAT gateway to report available before testing outbound traffic.

Cost: billing starts as soon as the NAT gateway is available and continues, idle or not, until you delete it. Its Elastic IP is billed separately and keeps billing after the NAT gateway is gone until you release it. If you're only experimenting, finish the build in one sitting and follow Tearing It Down at the end.

  1. VPC โ†’ NAT gateways โ†’ Create NAT gateway
  2. Name: nat-gw-1a
  3. Availability mode: Zonal (the regional mode is described below)
  4. Subnet: public-subnet-1a (a zonal NAT gateway goes in a PUBLIC subnet)
  5. Connectivity type: Public. Elastic IP: click "Allocate Elastic IP" (billed like any public IPv4 address)
  6. Create

It takes a few minutes to become available. Check the state before you point routes at it:

Bash
# Should print the NAT gateway ID and "available"
aws ec2 describe-nat-gateways --filter Name=vpc-id,Values=vpc-0abc123def456789 \
  --query "NatGateways[].[NatGatewayId, State]" --output text

A NAT gateway lives in one Availability Zone. With only nat-gw-1a, the app servers in us-east-1b lose outbound access if us-east-1a has problems, and their traffic crosses AZs. For production, create a second NAT gateway in public-subnet-1b and give the 1b private subnets their own route table pointing at it. For a staging or dev environment, one is enough.

Since November 2025 there's a second option: a NAT gateway in Regional availability mode. You pick the VPC instead of a subnet, it doesn't need a public subnet, it expands into each AZ where you have workloads (AWS says this can take up to 60 minutes for a new AZ), and every private route table can point at the same NAT gateway ID. It's billed per hour for each AZ it's active in, so two AZs cost about the same as two zonal NAT gateways. Regional mode doesn't support private NAT. This guide uses a zonal gateway because it makes each route visible; see Regional NAT gateways to switch.

Private Route Table

  1. Create route table: private-rt
  2. Add route: 0.0.0.0/0 โ†’ Target: NAT Gateway โ†’ Select your NAT gateway
  3. Associate with all 4 private subnets

Private instances can now pull updates and call external APIs. Inbound connections from the internet still cannot reach them.

S3 Gateway Endpoint

Traffic from private subnets to S3 would otherwise go through the NAT gateway and pay its per-GB processing charge. A gateway endpoint for S3 (and DynamoDB, if you use it) has no charge and keeps that traffic off the NAT gateway:

  1. VPC โ†’ Endpoints โ†’ Create endpoint
  2. Service: com.amazonaws.us-east-1.s3 with type Gateway
  3. VPC: production-vpc; route tables: private-rt
  4. Create

AWS adds a route to private-rt whose destination is the S3 prefix list; because it's more specific than 0.0.0.0/0, S3 traffic in the same Region uses the endpoint. Security groups and NACLs still have to allow HTTPS to S3. See Gateway endpoints.

๐Ÿ’ก No internet from private subnet?

Instance can't reach the internet from a private subnet? Walk the path: is the subnet associated with private-rt? Does that table have a 0.0.0.0/0 route to the NAT gateway? Is the NAT gateway available and in a public subnet? Does that subnet's route table have a route to the internet gateway? Every link in the chain has to be there.

Data-Tier Network ACL

🔒 A second layer for the data subnets:

Create a network ACL and associate it with private-data-1a and private-data-1b. Inbound: rule 100 ALLOW TCP 5432 from 10.0.10.0/23 (the two app subnets), then the default * DENY. Outbound: rule 100 ALLOW TCP 1024-65535 to 10.0.10.0/23 (the ephemeral ports the replies go back to), then * DENY. NACLs are stateless, which is why the return traffic needs its own outbound rule. With this in place, the database subnets only exchange traffic with the app tier on the Postgres port, even if someone later loosens the database's security group. NACLs apply to everything entering or leaving the subnet, so test anything else that needs to cross it (management tools, replication between your own database instances) and add rules for it explicitly.

Step 6: Application Load Balancer

The ALB goes in the public subnets. It forwards requests to your app servers in the private subnets.

Cost: an ALB bills an hourly charge plus Load Balancer Capacity Units from the moment it's created, even with no targets, and each of its public IPv4 addresses is billed as well. Delete it with the rest of the build if this is a test.

  1. Go to EC2 โ†’ Load Balancers
  2. Create load balancer โ†’ Choose Application Load Balancer
  3. Name: production-alb
  4. Scheme: Internet-facing
  5. IP address type: IPv4

Network Mapping

  • VPC: Select your VPC
  • Mappings: Select both AZs and their public subnets

Security Group

Create a new security group for the ALB:

  • Name: alb-sg
  • Inbound: HTTP (80) from 0.0.0.0/0
  • Inbound: HTTPS (443) from 0.0.0.0/0

Target Group

  1. Create a target group
  2. Target type: Instances
  3. Name: app-targets
  4. Protocol: HTTP, Port: 80
  5. VPC: Your VPC
  6. Health check path: /health (or / if you don't have a health endpoint)

Leave the target group empty for now. Instances get registered after launch.

Listener

Set the default listener action to forward to your target group. Create the ALB. For HTTPS, add a 443 listener with an ACM certificate in the same Region as the ALB, and make the port 80 listener redirect to HTTPS.

Step 7: Launch App Servers

Create EC2 instances in the private app subnets (one in each, for the two-AZ layout). The launch wizard itself is covered in Launch Your First EC2 Instance; here only the network settings change:

Terminal window with the commands from the section Step 7: Launch App Servers
User data runs once at first boot, and needs the NAT route to reach the package repositories.
  1. Launch Instance, AMI Amazon Linux 2023, instance type t3.micro
  2. Network settings โ†’ Edit: select your VPC
  3. Subnet: private-app-1a (and a second instance in private-app-1b)
  4. Auto-assign public IP: Disable (it's a private subnet)
  5. Security group: app-sg, described below
  6. Advanced details โ†’ IAM instance profile: a role with AmazonSSMManagedInstanceCore if you'll use Session Manager (Step 8)
  7. Advanced details โ†’ User data: the script below, so the instance has something to serve
User data
#!/bin/bash
# Runs once at first boot. Needs the NAT gateway route to reach the package repositories.
dnf install -y nginx
rm -f /usr/share/nginx/html/index.html
echo "hello from $(hostname)" > /usr/share/nginx/html/index.html
echo "ok" > /usr/share/nginx/html/health
systemctl enable --now nginx

If the instance launches before the NAT gateway route exists, dnf can't reach its repositories and the script fails. The output is in /var/log/cloud-init-output.log on the instance.

Security Group for App Servers

  • Name: app-sg
  • Inbound: HTTP (80) from alb-sg (the ALB's security group, not 0.0.0.0/0)
  • Inbound: SSH (22) from bastion-sg only, and only if you use a bastion (Session Manager needs no inbound rule)

App servers accept traffic ONLY from the load balancer, not from the internet directly. Referencing alb-sg instead of an IP range keeps working when the ALB's addresses change.

Register with Target Group

  1. EC2 โ†’ Target Groups โ†’ app-targets
  2. Register targets
  3. Select your instances
  4. Include as pending

On the target group's Targets tab, both instances move from initial to healthy after they pass enough consecutive health checks, which takes a few minutes with the default settings. Then curl http://production-alb-1234567890.us-east-1.elb.amazonaws.com/ (your ALB's DNS name) should alternate between the two "hello from" hostnames.

Step 8: Admin Access (Session Manager or Bastion)

You still need a way to get a shell on private instances. Two options:

Editor window showing ~/.ssh/config from the section Step 8: Admin Access (Session Manager or Bastion)
An SSH config entry for the bastion, so you don't retype its address and key.

Option A: Session Manager (No Inbound Ports)

AWS Systems Manager Session Manager opens a shell through the SSM agent, so you don't need a bastion, an open SSH port or SSH keys, and access is controlled with IAM policies and recorded in CloudTrail (Session Manager documentation). What the instances need:

  • The SSM agent, which is preinstalled on Amazon Linux 2023 and the Ubuntu Server AMIs.
  • An instance profile role with the AmazonSSMManagedInstanceCore managed policy.
  • Outbound HTTPS to the Systems Manager endpoints: through the NAT gateway here, or through interface VPC endpoints if the subnet has no internet route (the setup guide lists which ones).

Then start a session from the EC2 console (Connect โ†’ Session Manager) or with aws ssm start-session --target <instance-id> after installing the Session Manager plugin for the CLI.

Option B: Bastion Host

A bastion host is a small instance in the public subnet. You SSH to it first, then jump to private instances from there. It's the only instance with a public IP.

  1. Launch a t3.micro in public-subnet-1a with a public IPv4 address
  2. Security group bastion-sg: SSH (22) from your IP only
  3. This is the only SSH path into your private network; harden it as in Locking Down SSH

SSH config for easy access:

~/.ssh/config
# Bastion
Host bastion
 HostName 54.xxx.xxx.xxx # Bastion public IP
 User ec2-user
 IdentityFile ~/.ssh/my-key.pem

# App server (through bastion)
Host app-server
 HostName 10.0.10.xxx # Private IP
 User ec2-user
 IdentityFile ~/.ssh/my-key.pem
 ProxyJump bastion

Now ssh app-server automatically jumps through the bastion. ProxyJump keeps your private key on your own machine; don't copy keys onto the bastion. (ec2-user is the Amazon Linux username; Ubuntu AMIs use ubuntu.)

Step 9: Database in Private Data Subnet

Cost: an RDS instance bills per hour while it exists, plus storage and backups; Multi-AZ roughly doubles the instance charge, and a stopped instance restarts on its own after seven days. Check the RDS pricing page for the instance class you pick.

In the RDS console, Create database with Standard create and the PostgreSQL engine, then set the connectivity options for the data tier:

  • VPC: Your VPC
  • Subnet group: Create a new one with private-data-1a and private-data-1b (a DB subnet group must cover at least two AZs)
  • Credentials: choosing Managed in AWS Secrets Manager keeps the master password out of your notes and shell history
  • Public access: No
  • Security group: Allow port 5432 (Postgres) or 3306 (MySQL) from app-sg only
  • Multi-AZ: optional; it adds a standby in the other data subnet, which adds to the cost

No public access, no route from the internet gateway, a security group locked to app-sg, and the data-tier NACL. If you use MySQL instead of Postgres, change the NACL's 5432 rule to 3306 as well.

Once the status is Available, copy the endpoint from the Connectivity & security tab. From a shell on an app server, Bash's built-in /dev/tcp tests the port without installing a client:

Bash (on an app server)
timeout 3 bash -c '</dev/tcp/mydb.abc123xyz.us-east-1.rds.amazonaws.com/5432' \
  && echo "port 5432 open" || echo "blocked or unreachable"

Turn On VPC Flow Logs

Flow logs record the IP traffic going to and from network interfaces in the VPC, including whether security groups and NACLs accepted or rejected it. Create one for production-vpc (VPC โ†’ select it โ†’ Flow logs tab โ†’ Create flow log), filter All, destination CloudWatch Logs or S3. A CloudWatch Logs destination also needs an IAM role that the Flow Logs service can assume to write to the log group; an S3 destination doesn't. Each record in the default format includes source and destination address and port, protocol, packet and byte counts and an action field of ACCEPT or REJECT. Flow log data is billed as log ingestion and storage. See VPC Flow Logs.

Useful things to watch for: rejected connections to the data subnets from anything other than 10.0.10.0/23, and app servers connecting to each other or to unexpected ports. A CloudWatch metric filter on REJECT records for the data subnets plus an alarm turns that into a notification.

Testing the Architecture

  1. Get the ALB DNS name (something.elb.amazonaws.com)
  2. Open it in a browser โ€” your app loads (once the targets are healthy)
  3. Check the app servers in the EC2 console: they should have no public IPv4 address, so there is nothing on the internet to connect to
  4. From your laptop, try the RDS endpoint on port 5432 โ€” it should fail (time out)
  5. From a shell on an app server (Session Manager, or SSH through the bastion), the RDS endpoint should accept connections; from the bastion itself it should not

The same /dev/tcp check from Step 9 covers points 4 and 5: run it on an app server (expect "open") and on the bastion or your laptop (expect "blocked or unreachable"). Without public access, the RDS hostname resolves to a private address, so from outside the VPC the connection can't even be routed.

Traffic enters through the ALB and nowhere else. If step 4 succeeds, or the bastion can reach the database, the security groups or NACL are too open.

Costs

What this setup bills for in us-east-1 (check the pricing pages for current rates):

  • NAT Gateway: per hour plus per GB processed; at the $0.045/hour rate above, about $33 a month each before data, so two AZs doubles it. Usually the largest networking cost in a small VPC. A regional NAT gateway is billed the same hourly rate for each AZ it's active in.
  • Public IPv4 addresses: since February 1, 2024, AWS charges $0.005 per hour for every public IPv4 address, whether it's attached to a running resource or idle, which is about $3.65 for a 730-hour month (see the AWS announcement). In this design that includes the NAT gateway's Elastic IP, the internet-facing ALB's addresses and the bastion's address. The legacy Free Tier's 750 free hours a month of public IPv4 usage applied only during an account's first 12 months, and that period has ended for every account created before July 15, 2025. Accounts created on or after that date are on the credit-based Free Tier plan, which draws eligible charges from the account's credits, so check the Billing and Cost Management console to see what's left.
  • ALB: an hourly charge plus Load Balancer Capacity Units (LCUs) that depend on traffic (ELB pricing).
  • RDS, EC2 and Flow Logs: instance hours, storage and log ingestion.

For dev environments: run a single AZ with one NAT gateway (or none), add the free S3 gateway endpoint, and use Session Manager instead of a bastion. Interface endpoints (for ECR, Systems Manager and others) are billed per hour per AZ, so they replace a NAT gateway's cost rather than remove it. Delete the NAT gateway and release its Elastic IP when the environment isn't in use.

Troubleshooting

  • Instance in a "public" subnet can't be reached from the internet. Check the subnet's route table association: if it isn't explicitly associated with public-rt, it uses the main route table, which has no internet route. Then check the instance actually has a public IPv4 address, and that its security group allows the port.
  • Security group vs NACL. Security groups are stateful: if inbound is allowed, the reply is allowed automatically. NACLs are stateless and evaluated in rule-number order: the reply needs its own rule, usually for ephemeral ports 1024-65535. If traffic works with the default NACL but fails with yours, the return rule is the first suspect.
  • ALB returns 502 or 503, targets "unhealthy". The app security group must allow the target port from alb-sg, the health-check path must return a success code, and the app must listen on the port in the target group. The target's health status in the console shows the reason code.
  • App server can't reach the database. Check, in order: the RDS security group allows 5432 from app-sg; the data NACL allows 5432 inbound from 10.0.10.0/23 and 1024-65535 outbound back to it; the app server's own NACL (the default one allows all) isn't blocking; you're using the RDS endpoint hostname.
  • Find which layer drops it. Look up the flow between the two private IPs in VPC Flow Logs. A REJECT means a security group or NACL dropped it; no record at all usually means the traffic never left the source (routing or DNS).
  • Ask Reachability Analyzer. Give it a source and destination (for example an app server's network interface and the RDS endpoint's interface, port 5432) and it reports whether a path exists and, if not, which route table, security group or NACL blocks it. It analyzes configuration without sending packets and is billed per analysis ($0.10 each on the VPC pricing page).
  • Instance doesn't appear in Session Manager. Check that it has an instance profile with AmazonSSMManagedInstanceCore, that its subnet can reach the Systems Manager endpoints over HTTPS (NAT route or interface endpoints), and that the agent is running. Attaching the role to a running instance can take a few minutes to register (troubleshooting managed node availability).
  • User data didn't install Nginx. Read /var/log/cloud-init-output.log. A timeout reaching the package repositories means the private route table had no working NAT route when the instance booted; fix the route, then terminate and relaunch, since user data only runs at first boot.

Quick Checklist

  • โ˜ VPC with /16 CIDR
  • โ˜ 2+ public subnets in different AZs
  • โ˜ 4+ private subnets (app + data tiers)
  • โ˜ Internet gateway attached
  • โ˜ Public route table with 0.0.0.0/0 โ†’ IGW, associated with the public subnets
  • โ˜ NAT gateway in public subnet
  • โ˜ Private route table with 0.0.0.0/0 โ†’ NAT, associated with all private subnets
  • โ˜ S3 gateway endpoint on the private route table
  • โ˜ ALB in public subnets
  • โ˜ App servers in private subnets
  • โ˜ DB in private data subnet, with the data-tier NACL
  • โ˜ Security groups chained ALB โ†’ app โ†’ DB
  • โ˜ Flow logs enabled

Tearing It Down

Most of the hourly cost is in the NAT gateway, the ALB, RDS, the instances and the public IPv4 addresses. A VPC can't be deleted while anything that created a network interface in it still exists, so remove those first, in this order:

Terminal window with the commands from the section Tearing It Down
Delete the NAT gateway first, since it bills by the hour, and wait for "deleted" before releasing its Elastic IP.
  1. RDS: select the database โ†’ Actions โ†’ Delete. Decide whether to keep a final snapshot (snapshots are billed for storage). If deletion protection is on, turn it off with Modify first. After it's gone, delete the DB subnet group.
  2. Load balancer: delete production-alb, then the app-targets target group.
  3. Instances: terminate the app servers and the bastion.
  4. NAT gateway: delete it, wait until its state is deleted, then release its Elastic IP. The address keeps billing until it's released.
  5. Interface endpoints, if you created any for Systems Manager.
  6. VPC: Your VPCs โ†’ Actions โ†’ Delete VPC. The console also deletes the subnets, route tables, internet gateway, network ACLs, security groups and the S3 gateway endpoint, and lists anything still blocking it.
  7. Flow log data: the flow log itself is removed with the VPC, but the CloudWatch log group or S3 objects it wrote stay until you delete them.
Bash
aws ec2 delete-nat-gateway --nat-gateway-id nat-0abc123def456789

# Repeat until it prints "deleted"
aws ec2 describe-nat-gateways --nat-gateway-ids nat-0abc123def456789 \
  --query "NatGateways[].State" --output text

aws ec2 release-address --allocation-id eipalloc-0abc123def456789

# Verify nothing billable is left: no Elastic IPs, and the VPC is gone
aws ec2 describe-addresses --query "Addresses[].PublicIp" --output text
aws ec2 describe-vpcs --vpc-ids vpc-0abc123def456789   # expect InvalidVpcID.NotFound

The console steps follow AWS's Delete your VPC page, which also has the full CLI sequence.

Next Steps