Build a Multi-Tier VPC Network via CLI
Deploy a production-style AWS VPC with public and private subnets using the CLI.
Introduction
30 Second Summary
Every company running in the cloud depends on a well-designed network underneath. If you already hold a CCNA, you know subnetting, routing, and ACLs. The missing piece is proving you can build those same patterns in AWS.
In this project, you will build a production-style multi-tier VPC network on AWS entirely from the command line. You will create public and private subnets, configure internet connectivity, deploy EC2 instances, and verify end-to-end traffic flow.
What You'll Build
A complete three-tier VPC architecture with public and private subnets across two Availability Zones, internet and NAT gateways, route tables, security groups, and two EC2 instances proving connectivity.
By the end of this project, you'll have:
- A VPC with 4 subnets (2 public, 2 private) spread across 2 Availability Zones, built entirely using the AWS CLI.
- An Internet Gateway and NAT Gateway with route tables controlling traffic flow between your subnets and the internet.
- Security groups implementing least-privilege access control between a web tier and a database tier, with EC2 instances verifying the architecture works.
- Secret Mission: Add Network ACLs as a stateless defense layer, translating your CCNA ACL knowledge directly into AWS.
Are there any prerequisites?
You need an AWS account and a terminal on macOS. This project assumes you have CCNA-level networking knowledge (subnetting, routing, NAT, ACLs). You will install the AWS CLI in Step 1.
Install and Configure the AWS CLI
Every resource you build in this project gets created through the AWS CLI. Instead of clicking through the AWS Management Console, you'll type commands that talk directly to the same APIs that power the console.
In this step, you'll install the CLI on your Mac and connect it to your AWS account. Once configured, your terminal becomes a direct line to every AWS service.
In this step, get ready to:
- Install AWS CLI v2 on your Mac.
- Configure your AWS credentials and default region.
- Verify you have permission to manage VPC resources.
Install AWS CLI v2 on macOS
The AWS CLI is a unified tool that lets you control AWS services from your terminal. Version 2 is the current major release and the one this project requires.
- Check whether you already have the AWS CLI installed by running:
aws --version
✔️ I see aws-cli/2.x.x
You already have AWS CLI v2 installed. Skip ahead to the next substep where you'll configure your credentials.
ⓧ I see aws-cli/1.x.x
You have the older version 1 of the AWS CLI. The v2 installer does not automatically replace v1, so you need to install v2 separately. After installation, your system may still call the v1 binary if it appears first in your PATH.
- Download and install the official AWS CLI v2 package by running these commands:
curl "https://awscli.amazonaws.com/AWSCLIV2.pkg" -o "AWSCLIV2.pkg"
sudo installer -pkg AWSCLIV2.pkg -target /
What does this command do?
The first line downloads the installer package from AWS. The second line runs the macOS package installer with elevated permissions.
Your terminal will display Password: after the sudo command. Type your Mac login password and press Enter. No characters will appear as you type.
- After installation completes, verify the v2 binary is accessible by running:
which aws
You should see /usr/local/bin/aws in the output. If your terminal still points to the v1 binary, close your terminal and open a new one, then run aws --version again to confirm it now shows aws-cli/2.x.x.
ⓧ Command not found
The AWS CLI is not installed on your machine yet. You'll download the official macOS installer directly from AWS.
- Download and install the official AWS CLI v2 package by running these commands:
curl "https://awscli.amazonaws.com/AWSCLIV2.pkg" -o "AWSCLIV2.pkg"
sudo installer -pkg AWSCLIV2.pkg -target /
What does this command do?
The first line downloads the installer package from AWS. The second line runs the macOS package installer with elevated permissions.
Your terminal will display Password: after the sudo command. Type your Mac login password and press Enter. No characters will appear as you type.
- Verify the installation succeeded by running:
aws --version
You should see a version string starting with aws-cli/2. followed by the specific version number.
- Confirm the binary location by running:
which aws
You should see /usr/local/bin/aws in the output.
Still seeing command not found after installing?
Close your terminal completely and open a new window. The installation adds the CLI to your system PATH, but existing terminal sessions may not pick up the change.
help me troubleshoot AWS CLI installation on macOS
Configure AWS credentials
The CLI needs credentials to authenticate against your AWS account. You'll provide an Access Key ID and Secret Access Key that link your terminal commands to your IAM user.
Where do I find my Access Keys?
In the AWS Management Console, go to IAM, select Users, click your username, then open the Security credentials tab. Under Access keys, click Create access key.
Copy both the Access Key ID and Secret Access Key immediately. The Secret Access Key is only shown once at creation time.
- Start the credential configuration wizard by running the command below. It will prompt you for four values interactively:
aws configure
- When prompted for AWS Access Key ID, paste your Access Key ID.
- When prompted for AWS Secret Access Key, paste your Secret Access Key.
- When prompted for Default region name, enter us-east-1.
- When prompted for Default output format, enter json.
What does this command do?
This stores your credentials in ~/.aws/credentials and your region and output preferences in ~/.aws/config. Every CLI command you run from now on uses these values automatically.
- Verify your credentials are working by running:
aws sts get-caller-identity
You should see a JSON response containing your Account ID, User ID, and ARN. This confirms your terminal can authenticate with AWS.
- Verify you have VPC permissions by running:
aws ec2 describe-vpcs
You should see a JSON response listing your account's existing VPCs. If this returns without error, you have the permissions needed for the rest of this project.
Seeing an access denied or invalid credentials error?
Double check that you pasted the correct Access Key ID and Secret Access Key. Run aws configure again to re-enter them.
Make sure your IAM user has the AmazonVPCFullAccess and AmazonEC2FullAccess policies attached. Without these, the VPC commands in later steps will fail.
help me fix AWS CLI credential errors
Clean up the installer file
- Remove the downloaded installer package by running:
rm AWSCLIV2.pkg
The installer has already done its job. Removing the .pkg file frees up disk space without affecting the installed CLI.
Your CLI is installed and authenticated. Next up, you'll use it to create a VPC and carve it into subnets across multiple Availability Zones.
Create the VPC and Subnets
Your AWS CLI is installed and talking to your account. Now it's time to build the network itself.
Every cloud deployment starts with a VPC. Think of it as the boundary of your network. Inside the VPC, you'll carve out subnets to separate traffic, the same way VLANs segment a physical switch.
In this step, get ready to:
- Create a VPC with a /16 address space.
- Create two public subnets spread across separate Availability Zones.
- Create two private subnets spread across separate Availability Zones.
Create the VPC and enable DNS
A VPC is your isolated section of the AWS cloud. You pick a CIDR block to define how large the address space is. For this project you'll use 10.0.0.0/16, which gives you 65,536 addresses to subnet from.
- Create the VPC by running this command:
aws ec2 create-vpc --cidr-block 10.0.0.0/16 --tag-specifications 'ResourceType=vpc,Tags=[{Key=Name,Value=MyVPC}]'
What does this command do?
This creates an empty VPC with a /16 address range and tags it with the name MyVPC. If you've configured VLANs on a switch before, this is the equivalent of standing up a brand new Layer 3 domain.
- Look for the VpcId field in the JSON output (it starts with vpc-). Copy it somewhere you can easily paste from. You'll use it in every command for the rest of the project.
Keep IDs handy
Open a blank text file or sticky note to collect resource IDs as you go. Each new resource depends on IDs from earlier commands.
AWS creates VPCs with DNS support enabled by default, but DNS hostnames are disabled. You need both so your instances can resolve each other by hostname later.
- Enable DNS support and DNS hostnames on your VPC by running these commands:
aws ec2 modify-vpc-attribute --vpc-id [[VPCID="your VPC ID"]] --enable-dns-support
aws ec2 modify-vpc-attribute --vpc-id [[VPCID="your VPC ID"]] --enable-dns-hostnames
Why enable DNS hostnames?
When DNS hostnames are enabled, every EC2 instance launched in the VPC receives a public DNS name automatically. Without this, your web server would be reachable only by raw IP address.
- Confirm both commands returned successfully. Each one produces no output if it worked. If you see an error, double-check your VPC ID.
Seeing InvalidVpcID.NotFound?
This means the VPC ID you pasted doesn't match any VPC in your configured region. Make sure you copied the full ID (including the vpc- prefix) and that your CLI region is still set to us-east-1.
Help me fix this error
Create public subnets across two Availability Zones
Public subnets will host resources that need to be reachable from the internet. Spreading them across two Availability Zones gives you high availability. If one data centre has an issue, the other keeps serving traffic.
- Create the first public subnet in us-east-1a by running:
aws ec2 create-subnet --vpc-id [[VPCID="your VPC ID"]] --cidr-block 10.0.0.0/24 --availability-zone us-east-1a --tag-specifications 'ResourceType=subnet,Tags=[{Key=Name,Value=Public-Subnet-AZ1}]'
- Note the SubnetId from the output (starts with subnet-). Label it as your Public Subnet AZ1 ID.
- Create the second public subnet in us-east-1b by running:
aws ec2 create-subnet --vpc-id [[VPCID="your VPC ID"]] --cidr-block 10.0.1.0/24 --availability-zone us-east-1b --tag-specifications 'ResourceType=subnet,Tags=[{Key=Name,Value=Public-Subnet-AZ2}]'
- Note the SubnetId from the output. Label it as your Public Subnet AZ2 ID.
What does /24 mean here?
Each /24 subnet carves 256 addresses out of your /16 VPC. AWS reserves 5 addresses in every subnet (first four and last one), leaving you 251 usable IPs per subnet.
This is the same subnetting math from your CCNA studies. The VPC's /16 is the supernet, and each /24 subnet is a segment you route between.
Getting an InvalidParameterValue error?
Check that your CIDR block doesn't overlap with one you already created. Public Subnet AZ1 must be 10.0.0.0/24 and Public Subnet AZ2 must be 10.0.1.0/24. Also confirm the availability zone names are lowercase with no extra spaces.
Help me fix this error
Create private subnets across two Availability Zones
Private subnets host resources that should never be directly reachable from the internet, like databases. They still get the same multi-AZ treatment for redundancy.
- Create the first private subnet in us-east-1a by running:
aws ec2 create-subnet --vpc-id [[VPCID="your VPC ID"]] --cidr-block 10.0.2.0/24 --availability-zone us-east-1a --tag-specifications 'ResourceType=subnet,Tags=[{Key=Name,Value=Private-Subnet-AZ1}]'
- Note the SubnetId from the output. Label it as your Private Subnet AZ1 ID.
- Create the second private subnet in us-east-1b by running:
aws ec2 create-subnet --vpc-id [[VPCID="your VPC ID"]] --cidr-block 10.0.3.0/24 --availability-zone us-east-1b --tag-specifications 'ResourceType=subnet,Tags=[{Key=Name,Value=Private-Subnet-AZ2}]'
- Note the SubnetId from the output. Label it as your Private Subnet AZ2 ID.
How does this map to traditional networking?
Your VPC is the autonomous network boundary. Each subnet is a VLAN segment. In the next step you'll add route tables, which work exactly like the routing table on a Layer 3 switch.
The difference is that "public" vs "private" isn't decided by the subnet itself. It's decided by which route table you attach.
Confirm all four subnets exist by running the describe command below.
- Verify your subnets by running:
aws ec2 describe-subnets --filters "Name=vpc-id,Values=[[VPCID=\"your VPC ID\"]]"
- You should see four subnets in the output. Confirm each one shows the correct CidrBlock and AvailabilityZone.
Only seeing fewer than four subnets?
Make sure you ran all four create-subnet commands successfully. If a command returned an error you may have missed it. Re-run any failed commands with the correct CIDR block and availability zone.
Help me find my missing subnets
You now have a VPC with four subnets neatly separated across two Availability Zones. Next up, you'll connect this network to the internet by creating an Internet Gateway and configuring route tables.
Configure Internet Connectivity and Routing
Your VPC has four subnets, but none of them can reach the internet yet. In traditional networking, you'd plug a border router into your edge switch to give internal VLANs internet access. AWS uses an Internet Gateway for the exact same purpose.
In this step, you'll create an Internet Gateway and configure route tables to direct traffic between your subnets and the internet. Route tables in AWS work just like the routing tables on your Cisco devices. Public subnets get a default route pointing to the Internet Gateway, while private subnets stay isolated.
In this step, get ready to:
- Create an Internet Gateway and attach it to your VPC.
- Configure a public route table with a default route to the internet.
- Configure a private route table that keeps traffic internal.
Create and attach an Internet Gateway
An Internet Gateway is the AWS equivalent of a border router. It provides the bridge between your VPC and the public internet.
- Create the Internet Gateway by running this command:
aws ec2 create-internet-gateway --tag-specifications 'ResourceType=internet-gateway,Tags=[{Key=Name,Value=MyIGW}]'
What does this command do?
This creates a standalone Internet Gateway resource named MyIGW. It exists in your account but is not connected to any VPC yet.
Look for the InternetGatewayId in the output (it starts with igw-). Copy this value and keep it handy for the next commands.
- Attach the Internet Gateway to your VPC by running this command:
aws ec2 attach-internet-gateway --internet-gateway-id [[IGW_ID="your InternetGatewayId e.g. igw-0abc123def456"]] --vpc-id [[VPC_ID="your VpcId from Step 2"]]
What does this command do?
Attaching the Internet Gateway to your VPC is like patching a cable from your border router into your core switch. The gateway now sits at the edge of your VPC, ready to route traffic once you create the route table entries.
The command returns no output if successful. If you see no error, the attachment worked.
Seeing an InvalidParameterValue error?
Double-check that your --internet-gateway-id and --vpc-id values are correct. A common mistake is copying an extra space before or after the ID.
If you see a message about the gateway already being attached, it means you ran the command twice. That is fine. Move on to the next substep.
Help me troubleshoot my Internet Gateway attachment
Create the public route table and configure routes
A route table tells AWS where to send traffic leaving a subnet. Think of it as the ip route table on a Cisco router.
- Create the public route table by running this command:
aws ec2 create-route-table --vpc-id [[VPC_ID="your VpcId from Step 2"]] --tag-specifications 'ResourceType=route-table,Tags=[{Key=Name,Value=Public-RT}]'
What does this command do?
This creates a new route table named Public-RT inside your VPC. It automatically includes a local route for 10.0.0.0/16, which lets all subnets in the VPC talk to each other.
Look for the RouteTableId in the output (it starts with rtb-). Copy this value. You will use it in the next few commands.
- Add a default route that sends all internet-bound traffic (0.0.0.0/0) to the Internet Gateway:
aws ec2 create-route --route-table-id [[PUBLIC_RT_ID="your Public-RT RouteTableId e.g. rtb-0abc123"]] --destination-cidr-block 0.0.0.0/0 --gateway-id [[IGW_ID="your InternetGatewayId e.g. igw-0abc123def456"]]
What does this route do?
This is the equivalent of ip route 0.0.0.0 0.0.0.0 <next-hop> on a Cisco router. Any traffic that does not match a more specific route in the table gets forwarded to the Internet Gateway.
You should see "Return": true in the output, confirming the route was added.
- Associate the public route table with both public subnets by running these commands:
aws ec2 associate-route-table --route-table-id [[PUBLIC_RT_ID="your Public-RT RouteTableId"]] --subnet-id [[PUBLIC_SUBNET_AZ1_ID="your Public-Subnet-AZ1 SubnetId"]]
aws ec2 associate-route-table --route-table-id [[PUBLIC_RT_ID="your Public-RT RouteTableId"]] --subnet-id [[PUBLIC_SUBNET_AZ2_ID="your Public-Subnet-AZ2 SubnetId"]]
Why associate subnets explicitly?
Every VPC has a main route table that all subnets use by default. By explicitly associating your public subnets with Public-RT, you override the default and give them their own routing path to the internet.
Each command returns an AssociationId. You do not need to save these unless you plan to disassociate later.
- Enable auto-assign public IPs on both public subnets so any instance launched there automatically gets a public IP address:
aws ec2 modify-subnet-attribute --subnet-id [[PUBLIC_SUBNET_AZ1_ID="your Public-Subnet-AZ1 SubnetId"]] --map-public-ip-on-launch
aws ec2 modify-subnet-attribute --subnet-id [[PUBLIC_SUBNET_AZ2_ID="your Public-Subnet-AZ2 SubnetId"]] --map-public-ip-on-launch
Why enable auto-assign public IP?
Without this setting, EC2 instances launched in the public subnet would only get a private IP. They would have a route to the Internet Gateway but no public address for the internet to route back to. Enabling this ensures instances are truly publicly reachable.
Getting an InvalidSubnetID.NotFound error?
Make sure you are using the subnet IDs from Step 2, not the CIDR blocks. Subnet IDs start with subnet-.
Also confirm you are running commands in the same region (us-east-1) where you created the VPC.
Help me fix my route table association error
Create the private route table
Private subnets need their own route table that intentionally lacks a path to the Internet Gateway. This keeps them isolated from inbound internet traffic.
- Create the private route table by running this command:
aws ec2 create-route-table --vpc-id [[VPC_ID="your VpcId from Step 2"]] --tag-specifications 'ResourceType=route-table,Tags=[{Key=Name,Value=Private-RT}]'
What does this command do?
This creates Private-RT with only the local route (10.0.0.0/16). Private subnets can communicate within the VPC, but traffic destined for the internet has nowhere to go.
Copy the RouteTableId from the output (starts with rtb-). You will need this in the next step when you add a NAT Gateway route.
- Associate the private route table with both private subnets:
aws ec2 associate-route-table --route-table-id [[PRIVATE_RT_ID="your Private-RT RouteTableId e.g. rtb-0def456"]] --subnet-id [[PRIVATE_SUBNET_AZ1_ID="your Private-Subnet-AZ1 SubnetId"]]
aws ec2 associate-route-table --route-table-id [[PRIVATE_RT_ID="your Private-RT RouteTableId"]] --subnet-id [[PRIVATE_SUBNET_AZ2_ID="your Private-Subnet-AZ2 SubnetId"]]
Each command returns an AssociationId confirming the association.
- Verify your routing configuration by describing all route tables in your VPC:
aws ec2 describe-route-tables --filters "Name=vpc-id,Values=[[VPC_ID="your VpcId"]]"
What should I see?
You should see three route tables: the VPC's default main route table, plus your two custom ones (Public-RT and Private-RT).
Public-RT has two routes: the local route (10.0.0.0/16) and the default route (0.0.0.0/0) pointing to your Internet Gateway ID.
Private-RT has only the local route (10.0.0.0/16). No default route means no internet access for private subnets yet.
Not seeing the correct routes?
If Public-RT is missing the 0.0.0.0/0 route, the create-route command may have failed silently. Re-run the create-route command from earlier in this step.
If you see more than three route tables, your VPC may have leftover resources from a previous attempt. Make sure you are filtering by the correct VPC ID.
Help me verify my route tables are configured correctly
Your public subnets now have a clear path to the internet through the Internet Gateway. Next up, you'll add a NAT Gateway so your private subnets can reach the internet for updates without being exposed to inbound traffic.
Add NAT Gateway for Private Subnet Egress
Your private subnets are isolated from the internet. That is good for security, but those servers still need outbound access for software updates, API calls, and patch downloads.
A NAT Gateway solves this by allowing private instances to initiate outbound connections without being directly reachable from the internet. If you have used PAT (Port Address Translation) or NAT overload on a Cisco router, this is the AWS equivalent.
In this step, get ready to:
- Allocate an Elastic IP and create a NAT Gateway in your public subnet.
- Update the private route table to send outbound traffic through the NAT Gateway.
- Verify both route tables show correct routes.
Allocate an Elastic IP and create the NAT Gateway
A NAT Gateway needs a static public IP address so that return traffic from the internet knows where to come back to. AWS calls these static IPs Elastic IPs.
- Allocate an Elastic IP for your NAT Gateway by running this command:
aws ec2 allocate-address --domain vpc
What does this command do?
The --domain vpc flag tells AWS to allocate an IP address that works inside a VPC (as opposed to EC2-Classic, which is a legacy network mode).
- Find the AllocationId in the output (it looks like eipalloc-0abc123...). Save this value for the next command.
Now you can create the NAT Gateway itself. It must live in a public subnet so it can reach the internet through the Internet Gateway.
- Create the NAT Gateway in Public-Subnet-AZ1 by running this command:
aws ec2 create-nat-gateway --subnet-id [[PUBLIC_SUBNET_AZ1_ID="your Public-Subnet-AZ1 subnet ID"]] --allocation-id [[EIP_ALLOC_ID="your Elastic IP AllocationId"]] --tag-specifications 'ResourceType=natgateway,Tags=[{Key=Name,Value=MyNATGateway}]'
Why place the NAT Gateway in a public subnet?
The NAT Gateway translates private IP addresses to its own Elastic IP for outbound traffic. It needs to be in a public subnet so it has a route to the Internet Gateway.
Think of it like the inside interface of a Cisco router doing NAT. The router sits between the private and public networks, and its outside interface must have a routable public address.
- Find the NatGatewayId in the output (it looks like nat-0abc123...). Save this value.
NAT Gateways take 1-2 minutes to provision. You need to wait for it to become available before adding routes that point to it.
- Wait for the NAT Gateway to become available by running this command:
aws ec2 wait nat-gateway-available --nat-gateway-ids [[NAT_GW_ID="your NatGatewayId"]]
- When the command finishes (no output, just a new prompt), your NAT Gateway is ready.
Command seems stuck?
The wait command polls AWS every 15 seconds until the NAT Gateway reaches the available state. It typically takes 1-2 minutes. If it takes longer than 5 minutes, press Ctrl+C and check the NAT Gateway state manually with aws ec2 describe-nat-gateways.
help me troubleshoot why my NAT Gateway is not reaching available state
Add default route to the private route table via NAT Gateway
Right now your private route table only has the local route (10.0.0.0/16). Private instances can talk to other instances in the VPC, but any traffic destined for the internet gets dropped.
- Add a default route to the private route table pointing to your NAT Gateway by running this command:
aws ec2 create-route --route-table-id [[PRIVATE_RT_ID="your Private-RT route table ID"]] --destination-cidr-block 0.0.0.0/0 --nat-gateway-id [[NAT_GW_ID="your NatGatewayId"]]
What does this route do?
This adds a 0.0.0.0/0 default route to the private route table. Any traffic from private instances that does not match the local VPC range (10.0.0.0/16) gets forwarded to the NAT Gateway.
This is exactly like adding ip route 0.0.0.0 0.0.0.0 <next-hop> on a Cisco router. The NAT Gateway is your next-hop for outbound traffic from the private network.
- You should see "Return": true in the output, confirming the route was added.
Seeing an error about the NAT Gateway?
If you see an error mentioning the NAT Gateway ID is invalid, double-check you copied the NatGatewayId (starts with nat-) from the create-nat-gateway output. Also confirm the wait command finished successfully, meaning the gateway is in available state.
help me fix the route creation error for my NAT Gateway
Verify routing configuration
With both route tables configured, your VPC now has the complete routing picture. Public subnets route outbound traffic to the Internet Gateway. Private subnets route outbound traffic to the NAT Gateway.
- Verify both route tables by running this command:
aws ec2 describe-route-tables --filters "Name=vpc-id,Values=[[VPC_ID="your MyVPC VpcId"]]"
- Confirm you see the following routes in the output:
- Public-RT: 10.0.0.0/16 local route AND 0.0.0.0/0 pointing to your Internet Gateway (igw- prefix).
- Private-RT: 10.0.0.0/16 local route AND 0.0.0.0/0 pointing to your NAT Gateway (nat- prefix).
How does this compare to a traditional network?
You have just built the cloud equivalent of two routing zones. Your public zone routes directly to the border router (Internet Gateway). Your private zone routes through a NAT device that masks internal addresses before forwarding to the border.
On a Cisco network, you would see this as two interfaces on a router: one facing the public network with a routable IP, and one facing the private network doing NAT overload.
Cost warning
Your NAT Gateway is now charging $0.045 per hour. Complete the remaining steps and cleanup within the hour to keep costs minimal. If you need to step away, jump to the Before You Go section to delete the NAT Gateway first.
Route tables not showing the expected routes?
Make sure the --filters value matches your VPC ID exactly. If you see a third route table, that is the default main route table AWS creates with every VPC. Look for the ones tagged Public-RT and Private-RT.
help me interpret my route table output and verify my routing is correct
Your network routing is fully configured. Next up, you will add security groups and deploy EC2 instances to prove the whole architecture works end to end.
Deploy Security Groups and EC2 Instances
Your network infrastructure is fully wired. Routes, gateways, and subnets are all in place.
Now it is time to prove the architecture works by deploying actual compute resources. You will create security groups that enforce least-privilege access, then launch EC2 instances to validate end-to-end connectivity.
In this step, get ready to:
- Create security groups implementing least-privilege rules for a web tier and a database tier.
- Launch EC2 instances in the public and private subnets to prove the architecture works.
- Verify that the web server is reachable from the internet and the database server is properly isolated.
Create security groups for the web tier and database tier
Security groups are stateful firewalls that control inbound and outbound traffic at the instance level. If you have a CCNA background, think of them as stateful ACLs where return traffic is automatically allowed.
- Create the web server security group by running this command:
aws ec2 create-security-group --group-name WebServerSG --description "Security group for web servers" --vpc-id [[VPCID="your VPC ID from Step 2"]]
What does this command do?
This creates a new security group named WebServerSG inside your VPC. The security group starts with no inbound rules (all inbound traffic denied) and a default outbound rule that allows all traffic out.
- Note the GroupId from the output. You will use it in the next commands.
- Allow HTTP traffic on port 80 from anywhere by running:
aws ec2 authorize-security-group-ingress --group-id [[WEBSGID="your WebServerSG group ID"]] --protocol tcp --port 80 --cidr 0.0.0.0/0
- Allow HTTPS traffic on port 443 from anywhere by running:
aws ec2 authorize-security-group-ingress --group-id [[WEBSGID="your WebServerSG group ID"]] --protocol tcp --port 443 --cidr 0.0.0.0/0
What do these rules mean?
You are allowing any source IP (0.0.0.0/0) to connect to your web server on ports 80 and 443. This is the standard pattern for a public-facing web server.
Because security groups are stateful, the response traffic from your web server back to the requester is automatically allowed. You do not need to add explicit outbound rules for return traffic.
- Create the database server security group by running:
aws ec2 create-security-group --group-name DBServerSG --description "Security group for database servers" --vpc-id [[VPCID="your VPC ID from Step 2"]]
- Note the GroupId for DBServerSG from the output.
- Allow MySQL traffic on port 3306 only from the web server security group by running:
aws ec2 authorize-security-group-ingress --group-id [[DBSGID="your DBServerSG group ID"]] --protocol tcp --port 3306 --source-group [[WEBSGID="your WebServerSG group ID"]]
Why reference a security group instead of an IP?
The --source-group flag tells AWS to allow traffic from any instance that belongs to WebServerSG. This is more flexible than hardcoding IP addresses. If you add more web servers later, they automatically gain access to the database without rule changes.
In CCNA terms, this is like referencing an object-group in an ACL rather than individual host entries.
Getting a group not found error?
Make sure you are using the GroupId (starts with sg-) and not the group name. Also confirm the VPC ID matches the VPC where the security group was created.
help me fix a security group authorization error in AWS CLI
Deploy test EC2 instances
With security groups in place, you can launch instances to test the network. You will deploy a web server in the public subnet and a database server in the private subnet.
- Find the latest Amazon Linux 2 AMI ID by running this command:
aws ec2 describe-images --owners amazon --filters "Name=name,Values=amzn2-ami-hvm-*-x86_64-gp2" "Name=state,Values=available" --query "sort_by(Images, &CreationDate)[-1].ImageId" --output text
What does this command do?
This queries AWS for the latest Amazon Linux 2 AMI that matches the HVM virtualization type and x86_64 architecture. The sort_by and [-1] picks the most recently created image from the results.
Using a dynamic lookup avoids hardcoding an AMI ID that could change between regions or become outdated.
- Note the AMI ID from the output. It will look something like ami-0abcdef1234567890.
- Launch the web server instance in Public-Subnet-AZ1 by running:
aws ec2 run-instances \
--image-id [[AMIID="your AMI ID from the previous command"]] \
--count 1 \
--instance-type t2.micro \
--subnet-id [[PUBSUBNETAZ1="your Public-Subnet-AZ1 subnet ID"]] \
--security-group-ids [[WEBSGID="your WebServerSG group ID"]] \
--user-data '#!/bin/bash
yum update -y
yum install -y httpd
systemctl start httpd
systemctl enable httpd
echo "<h1>Hello from the public subnet</h1>" > /var/www/html/index.html' \
--tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=WebServer}]'
What does user-data do here?
The --user-data flag passes a bash script that runs automatically on first boot. This script installs the Apache web server (httpd), starts it, enables it on reboot, and creates a simple HTML page.
Because the public subnet has auto-assign public IP enabled and routes through the Internet Gateway, this instance will be reachable from the internet on port 80.
- Note the InstanceId for the web server from the output.
- Launch the database server instance in Private-Subnet-AZ1 by running:
aws ec2 run-instances \
--image-id [[AMIID="your AMI ID from the previous command"]] \
--count 1 \
--instance-type t2.micro \
--subnet-id [[PRIVSUBNETAZ1="your Private-Subnet-AZ1 subnet ID"]] \
--security-group-ids [[DBSGID="your DBServerSG group ID"]] \
--tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=DBServer}]'
Why no user-data for the database server?
The database server does not need to serve traffic from the internet. It only needs to accept MySQL connections from the web tier. For this test, you just need to confirm it launched in the private subnet with no public IP assigned.
- Note the InstanceId for the database server from the output.
- Wait for both instances to reach the running state by running:
aws ec2 wait instance-running --instance-ids [[WEBINSTANCEID="your web server instance ID"]] [[DBINSTANCEID="your database server instance ID"]]
What does this wait command do?
The wait instance-running command polls the instance state every 15 seconds. It returns control to you once both instances report a running state. This typically takes 30 to 60 seconds.
Wait command timing out?
The wait command exits with code 255 after 40 failed checks (about 10 minutes). If this happens, check that your instance IDs are correct and that you have not exceeded your region's EC2 instance limit.
help me debug why my EC2 instance is not reaching running state
Verify connectivity
The final test proves the architecture works end-to-end. You will confirm that the web server is reachable from the internet and that the database server is properly isolated.
- Get the public IP of your web server by running:
aws ec2 describe-instances --instance-ids [[WEBINSTANCEID="your web server instance ID"]] --query 'Reservations[0].Instances[0].PublicIpAddress' --output text
- Note the public IP address from the output.
- Test that the web server is reachable from the internet by running:
curl http://[[WEBPUBLICIP="your web server public IP address"]]
You should see HTML output containing the text you set in user-data.
What does this prove?
A successful curl response proves the entire public path works. The Internet Gateway routes inbound traffic to your public subnet, the security group allows HTTP on port 80, and the Apache web server is running.
- Confirm the database server has no public IP by running:
aws ec2 describe-instances --instance-ids [[DBINSTANCEID="your database server instance ID"]] --query 'Reservations[0].Instances[0].PublicIpAddress' --output text
You should see None as the output. This confirms the database server is isolated in the private subnet with no direct internet exposure.
How does this map to traditional networking?
Your public subnet is like a DMZ. It is internet-facing and hosts services that need inbound connections. Your private subnet is like an internal VLAN with no inbound internet path, but it can reach out through the NAT Gateway for software updates.
The security group reference between WebServerSG and DBServerSG implements the same principle as an inter-VLAN ACL that only permits specific traffic between zones.
Curl returning connection refused or timeout?
Check that WebServerSG allows inbound TCP on port 80 from 0.0.0.0/0. Verify the public route table has a 0.0.0.0/0 route to the Internet Gateway. Also confirm the instance is in the running state and that httpd started successfully via user-data.
help me troubleshoot why I cannot reach my EC2 web server from the internet
Your architecture is now proven. You have a publicly accessible web server and a fully isolated database server, both running in a production-style multi-tier VPC. Next up is the Secret Mission, where you will add Network ACLs as a second layer of defense.
Secret mission
Add Network ACLs as a Stateless Defense Layer
Your security groups provide stateful filtering, but production networks use defense-in-depth. In this mission, you will add a custom Network ACL to your private subnets, mirroring how you would apply numbered ACLs on a Cisco router. The twist: NACLs are stateless, so you must explicitly allow return traffic in both directions.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely. The NAT Gateway is charging $0.045/hour, so cleaning up promptly keeps costs minimal.
Cost warning
Your NAT Gateway costs $0.045 per hour. Your Elastic IP costs $0.005 per hour while not attached to a running instance. EC2 instances (t2.micro) are covered by Free Tier if eligible.
If you leave everything running, expect roughly $0.06/hour in charges. Unless you plan to continue building on this VPC immediately, delete all resources now.
Resources you used:
- 2 EC2 instances (Web Server in Public-Subnet-AZ1, Database Server in Private-Subnet-AZ1).
- 1 NAT Gateway (MyNATGateway) with 1 Elastic IP.
- 2 security groups (WebServerSG, DBServerSG).
- 1 custom Network ACL (Private-NACL) from the Secret Mission.
- 2 route tables (Public-RT, Private-RT).
- 1 Internet Gateway (MyIGW).
- 4 subnets (Public-Subnet-AZ1, Public-Subnet-AZ2, Private-Subnet-AZ1, Private-Subnet-AZ2).
- 1 VPC (MyVPC).
Keep everything running
No action needed. Choose this if you plan to continue experimenting with this VPC architecture in the next hour or two.
- Your NAT Gateway continues charging $0.045/hour.
- EC2 instances continue running (Free Tier eligible for t2.micro).
- AWS CLI remains installed on your Mac for future projects.
Pause - I'll come back to this later
Stop the EC2 instances to save compute costs, but keep the network infrastructure in place. The NAT Gateway and Elastic IP still incur charges.
- Stop both EC2 instances by running:
aws ec2 stop-instances --instance-ids [[WEB_INSTANCE_ID="your web server instance ID"]] [[DB_INSTANCE_ID="your database server instance ID"]]
- Your NAT Gateway ($0.045/hour) and Elastic IP ($0.005/hour) continue to charge. Only pause if you plan to resume within a few hours.
- When you are ready to resume, run the same command using start-instances instead of stop-instances.
Delete - I don't want to use this again
Remove all project resources and stop all charges. Run these commands in order because resources have dependencies on each other.
- Terminate both EC2 instances and wait for them to finish terminating:
aws ec2 terminate-instances --instance-ids [[WEB_INSTANCE_ID="your web server instance ID"]] [[DB_INSTANCE_ID="your database server instance ID"]]
aws ec2 wait instance-terminated --instance-ids [[WEB_INSTANCE_ID="your web server instance ID"]] [[DB_INSTANCE_ID="your database server instance ID"]]
- Delete the NAT Gateway and wait for it to finish deleting:
aws ec2 delete-nat-gateway --nat-gateway-id [[NAT_GW_ID="your NAT Gateway ID"]]
aws ec2 wait nat-gateway-deleted --nat-gateway-ids [[NAT_GW_ID="your NAT Gateway ID"]]
- Release the Elastic IP:
aws ec2 release-address --allocation-id [[EIP_ALLOC_ID="your Elastic IP allocation ID"]]
- Delete the security groups (database group first, then web group):
aws ec2 delete-security-group --group-id [[DB_SG_ID="your DBServerSG group ID"]]
aws ec2 delete-security-group --group-id [[WEB_SG_ID="your WebServerSG group ID"]]
- If you completed the Secret Mission, re-associate the private subnets with the default Network ACL, then delete Private-NACL. First, find your default NACL ID by running:
aws ec2 describe-network-acls --filters "Name=vpc-id,Values=[[VPC_ID="your VPC ID"]]" "Name=default,Values=true" --query "NetworkAcls[0].NetworkAclId" --output text
- Use the default NACL ID to replace the Private-NACL associations for both private subnets:
aws ec2 replace-network-acl-association --association-id [[NACL_ASSOC_1="first private subnet NACL association ID"]] --network-acl-id [[DEFAULT_NACL_ID="your default Network ACL ID"]]
aws ec2 replace-network-acl-association --association-id [[NACL_ASSOC_2="second private subnet NACL association ID"]] --network-acl-id [[DEFAULT_NACL_ID="your default Network ACL ID"]]
- Delete the custom Network ACL:
aws ec2 delete-network-acl --network-acl-id [[NACL_ID="your Private-NACL ID"]]
- Disassociate and delete both route tables. First, find the association IDs:
aws ec2 describe-route-tables --filters "Name=vpc-id,Values=[[VPC_ID="your VPC ID"]]" --query "RouteTables[?Tags[?Value=='Public-RT' || Value=='Private-RT']].Associations[].RouteTableAssociationId" --output text
- Disassociate each route table association ID returned above:
aws ec2 disassociate-route-table --association-id [[RT_ASSOC_ID="each association ID from the previous command"]]
- Delete both custom route tables:
aws ec2 delete-route-table --route-table-id [[PUBLIC_RT_ID="your Public-RT route table ID"]]
aws ec2 delete-route-table --route-table-id [[PRIVATE_RT_ID="your Private-RT route table ID"]]
- Detach and delete the Internet Gateway:
aws ec2 detach-internet-gateway --internet-gateway-id [[IGW_ID="your Internet Gateway ID"]] --vpc-id [[VPC_ID="your VPC ID"]]
aws ec2 delete-internet-gateway --internet-gateway-id [[IGW_ID="your Internet Gateway ID"]]
- Delete all four subnets:
aws ec2 delete-subnet --subnet-id [[PUB_SUBNET_AZ1="your Public-Subnet-AZ1 ID"]]
aws ec2 delete-subnet --subnet-id [[PUB_SUBNET_AZ2="your Public-Subnet-AZ2 ID"]]
aws ec2 delete-subnet --subnet-id [[PRIV_SUBNET_AZ1="your Private-Subnet-AZ1 ID"]]
aws ec2 delete-subnet --subnet-id [[PRIV_SUBNET_AZ2="your Private-Subnet-AZ2 ID"]]
- Finally, delete the VPC:
aws ec2 delete-vpc --vpc-id [[VPC_ID="your VPC ID"]]
How do I know everything is deleted?
Run the following command to confirm your VPC no longer exists:
aws ec2 describe-vpcs --vpc-ids [[VPC_ID="your VPC ID"]]
If you see an error saying the VPC does not exist, that confirms successful deletion.
Nice Work!
Nice Work!
You just built a production-style multi-tier VPC network on AWS entirely from the command line. That is the exact skill that separates CCNA holders from cloud network engineers.
Here's what you accomplished:
- Designed and deployed a multi-tier VPC with a /16 CIDR block and four subnets across two Availability Zones, translating traditional subnetting skills directly into AWS.
- Configured end-to-end internet connectivity using an Internet Gateway, a NAT Gateway, and custom route tables that mirror how you would configure routing on a physical network.
- Implemented least-privilege security groups and deployed EC2 instances to verify that your public web server is internet-reachable and your private database server is fully isolated.
- Secret Mission: Added Network ACLs as a stateless defense layer, translating your CCNA ACL knowledge directly into AWS.
Ready to quiz yourself?