Build a Secure Multi-Tier VPC with CLI
Build a production-style AWS VPC with public and private subnets using the CLI.
Introduction
30 Second Summary
Every organization keeps its most sensitive systems behind layers of restricted access, the same way a bank separates its vaults from the public lobby. The networks powering government agencies and enterprises need precise control over what traffic can flow where.
In this project, you will build a secure multi-tier network in AWS using only the AWS CLI. You will create subnets, configure layered firewalls, and enable traffic logging, all from the command line.
What You'll Build
Run a describe command and watch your terminal print back every subnet, every firewall rule, and every traffic log you placed there yourself.
By the end of this project, you'll have:
- A VPC with public and private subnets, an internet gateway, and a NAT Gateway that gives the private tier outbound-only internet access without exposing it to inbound traffic.
- Layered firewalls using security groups at the instance level and Network ACLs at the subnet level, each enforcing least-privilege access rules you wrote yourself.
- VPC Flow Logs streaming to CloudWatch so you can see exactly which connections succeeded and which were blocked on your network.
- Secret Mission: Extend your architecture across two Availability Zones for production-grade high availability.
Do I need any cloud experience?
No prior AWS or networking experience is required. This project includes full setup from account creation to CLI configuration.
You will need a credit or debit card for the AWS sign-up, but new accounts receive up to $200 in free credits that cover this project's costs.
Before We Start
You are about to build an enterprise-grade virtual network from scratch using the AWS CLI. This is the same tool that cloud teams use daily to deploy and manage infrastructure.
Before you can build anything, you need three things: an AWS account, an IAM user with the right permissions, and the AWS CLI installed and configured on your Windows machine.
In this step, get ready to:
- Create an AWS account and an IAM user with CLI access.
- Install and configure the AWS CLI on Windows.
- Verify your CLI can communicate with AWS.
Create an AWS account
Your AWS account is the foundation for everything you build in this project. You will sign up on the free plan, which gives new accounts up to $200 in credits.
- Open your browser and go to https://aws.amazon.com/free/.
- Click Create a Free Account (or Create an AWS Account).
- Enter your email address, choose a password, and pick an account name.
- Complete the sign-up form with your phone number and a credit or debit card.
- Select the Basic support - Free plan when prompted.
- Once your account is active, sign in to the AWS Management Console.
Will I be charged?
New AWS accounts receive up to $200 in free credits. The only cost in this project comes from the NAT Gateway (~$0.045/hour). If you delete your resources within the hour as instructed in Before You Go, the total cost stays well under $1.
Create an IAM user for CLI access
You should never use your root account credentials for day-to-day work. Instead, you will create a dedicated IAM user with admin permissions and generate access keys for the CLI.
- In the AWS Management Console, type IAM in the top search bar and select IAM from the results.
- In the left navigation pane, click Users.
- Click Create user.
- On the Specify user details page, enter a username (for example, cli-admin) and click Next.
- On the Set permissions page, select Attach policies directly.
- In the search box, type AdministratorAccess.
- Select the checkbox next to AdministratorAccess and click Next.
- Review the details and click Create user.
Now you need to generate an access key so the CLI can authenticate as this user.
- Click on your new username in the Users list to open its details page.
- Select the Security credentials tab.
- Scroll down to Access keys and click Create access key.
- On the Access key best practices & alternatives page, select Command Line Interface (CLI) as your use case.
- Check the confirmation box at the bottom and click Next.
- Click Create access key.
- Click Download .csv file and save it somewhere secure on your computer.
- Click Done.
Save your keys now
This is the only time AWS shows your Secret Access Key. If you lose it, you will need to delete the key and create a new one. Save the .csv file somewhere safe and never share it with anyone.
Install and configure the AWS CLI
The AWS CLI lets you control AWS services directly from your terminal. You will install it on Windows using PowerShell.
- Press the Windows key to open your search bar.
- Type PowerShell and press Enter to open it.
- Install the AWS CLI by running this command:
irm https://awscli.amazonaws.com/v2/install.ps1 | iex
What does this command do?
This downloads and runs the official AWS CLI v2 installer script directly from Amazon. The irm command fetches the script and iex executes it.
- Close PowerShell completely and reopen it (this refreshes your system path).
- Verify the installation by running:
aws --version
Check the output to see which scenario matches yours.
✔️ I see aws-cli/2.x.x
Your output shows something like aws-cli/2.27.41 Python/3.11.6 Windows/10 exe/AMD64. The AWS CLI is installed and ready.
ⓧ I see aws-cli/1.x.x
You have an older version installed. You need version 2 for this project.
- Open Settings on your Windows machine.
- Select Apps, then Apps & features.
- Search for "AWS CLI" and uninstall it.
- Close and reopen PowerShell.
- Run the install command above again to install v2.
- Run aws --version again to confirm you now see aws-cli/2.x.x.
ⓧ Command not found
The installation did not complete or your path was not updated.
- Make sure you closed and reopened PowerShell after running the install command.
- If it still fails, right-click PowerShell in the Start menu and select Run as administrator.
- Run the install command again from the elevated PowerShell window.
- Close and reopen PowerShell, then run aws --version again.
Now connect the CLI to your AWS account using the access keys you saved earlier.
- Configure the CLI by running:
aws configure
The CLI will prompt you for four values. Enter each one when prompted:
- AWS Access Key ID [None]: Paste your Access Key ID from the .csv file you downloaded.
- AWS Secret Access Key [None]: Paste your Secret Access Key from the same file.
- Default region name [None]: Type us-east-1.
- Default output format [None]: Type json.
What did this set up?
The aws configure command stores your credentials and preferences in a file at C:\Users\YourName\.aws\credentials. Every AWS CLI command you run from now on uses these settings to authenticate.
The region us-east-1 tells AWS where to create your resources. The output format json means command results come back as structured data you can read easily.
Verify CLI access
Time to confirm everything is connected. Before you run the next command, think about what you expect to see if your credentials are configured correctly.
- Test your connection by running:
aws sts get-caller-identity
You should see JSON output containing your Account number, your IAM user ARN, and a UserId. It looks something like this:
{
"UserId": "AIDAEXAMPLEID",
"Account": "123456789012",
"Arn": "arn:aws:iam::123456789012:user/cli-admin"
}
What does this prove?
The aws sts get-caller-identity command asks AWS "who am I?" If you see your account number and IAM user ARN, your CLI is authenticated and ready to build infrastructure.
Seeing an error instead of JSON?
If you see Unable to locate credentials, the aws configure step did not save correctly. Run aws configure again and re-enter your Access Key ID and Secret Access Key.
If you see InvalidClientTokenId, your Access Key ID is incorrect. Open the .csv file you downloaded and double-check you pasted the full key with no extra spaces.
Help me troubleshoot my AWS CLI credentials.
Your AWS account and CLI are ready to go. Next up, you will create the VPC that forms the foundation of your entire network architecture.
Create Your VPC and Public Subnet
Your CLI is connected to AWS and ready to build infrastructure. Every AWS network starts with a VPC, a virtual network that gives you an isolated section of the AWS cloud with its own address space.
In this step, you will create a VPC, carve out a public subnet inside it, and wire up an internet gateway so that resources in the public subnet can reach the outside world.
In this step, get ready to:
- Create a VPC with a 10.0.0.0/16 address space.
- Build a public subnet and attach an internet gateway.
- Configure a route table so the subnet can reach the internet.
Create the VPC
A CIDR block defines how many IP addresses your VPC can hold. The 10.0.0.0/16 range gives you over 65,000 addresses to carve into subnets later.
- Create your VPC by running this command in PowerShell:
aws ec2 create-vpc --cidr-block 10.0.0.0/16 --tag-specifications "ResourceType=vpc,Tags=[{Key=Name,Value=MyVPC}]" --query "Vpc.VpcId" --output text
You should see a VPC ID printed in your terminal, something like vpc-0abc123def456. Copy this value and save it somewhere handy. You will use it in almost every command from here on.
What does this command do?
- --cidr-block 10.0.0.0/16 reserves a block of 65,536 IP addresses for your VPC.
- --tag-specifications names the VPC MyVPC so you can identify it easily.
- --query "Vpc.VpcId" --output text filters the output to show only the VPC ID instead of the full JSON response.
- Enable DNS hostnames on your VPC by running this command (replace the VPC ID with your own):
aws ec2 modify-vpc-attribute --vpc-id [[VPC_ID="vpc-0abc123..."]] --enable-dns-hostnames "{\"Value\":true}"
Why enable DNS hostnames?
DNS hostnames let instances in your VPC receive readable domain names instead of just IP addresses. This is required for many AWS services to work correctly.
This command produces no output if it succeeds. No news is good news.
Seeing an error about the VPC ID?
- Make sure you replaced the placeholder with the actual VPC ID that was printed by the previous command.
- Check for extra spaces or missing characters in the ID. It should start with vpc- followed by a string of letters and numbers.
- help me fix my VPC ID error
Create a public subnet and internet gateway
A subnet is a smaller network inside your VPC. You will create one in a specific Availability Zone and then give it internet access through an internet gateway.
- Create a public subnet by running this command (replace the VPC ID with your own):
aws ec2 create-subnet --vpc-id [[VPC_ID="vpc-0abc123..."]] --cidr-block 10.0.1.0/24 --availability-zone us-east-1a --tag-specifications "ResourceType=subnet,Tags=[{Key=Name,Value=Public-Subnet}]" --query "Subnet.SubnetId" --output text
You should see a subnet ID printed, like subnet-0abc123def456. Save this value.
What does this command do?
- --cidr-block 10.0.1.0/24 carves out 256 addresses from your VPC's larger block for this subnet.
- --availability-zone us-east-1a places the subnet in a specific physical data center location.
- --query "Subnet.SubnetId" --output text gives you just the subnet ID.
- Create an internet gateway by running:
aws ec2 create-internet-gateway --tag-specifications "ResourceType=internet-gateway,Tags=[{Key=Name,Value=MyIGW}]" --query "InternetGateway.InternetGatewayId" --output text
You should see an internet gateway ID printed, like igw-0abc123def456. Save this value.
- Attach the internet gateway to your VPC by running (replace both IDs with your own):
aws ec2 attach-internet-gateway --internet-gateway-id [[IGW_ID="igw-0abc123..."]] --vpc-id [[VPC_ID="vpc-0abc123..."]]
What is an internet gateway?
An internet gateway is the door between your VPC and the public internet. Without it, nothing inside your VPC can send or receive traffic from the outside world.
Attaching it to the VPC makes the door available, but no traffic flows through it yet. You still need a route table to point traffic at it.
Configure routing for internet access
A route table contains rules that determine where network traffic is sent. You need one that says "any traffic headed for the internet should go through the internet gateway."
- Create a public route table by running (replace the VPC ID with your own):
aws ec2 create-route-table --vpc-id [[VPC_ID="vpc-0abc123..."]] --tag-specifications "ResourceType=route-table,Tags=[{Key=Name,Value=Public-RT}]" --query "RouteTable.RouteTableId" --output text
You should see a route table ID like rtb-0abc123def456. Save this value.
- Add a default route that sends all internet-bound traffic to your internet gateway (replace both IDs):
aws ec2 create-route --route-table-id [[PUBLIC_RT_ID="rtb-0abc123..."]] --destination-cidr-block 0.0.0.0/0 --gateway-id [[IGW_ID="igw-0abc123..."]]
What does 0.0.0.0/0 mean?
The destination 0.0.0.0/0 means "all IP addresses." This route says: if traffic is heading anywhere outside the VPC's own range, send it to the internet gateway.
This command produces no output on success. If you do not see an error, the route was created.
- Associate your public subnet with this route table so the subnet uses these routes (replace both IDs):
aws ec2 associate-route-table --route-table-id [[PUBLIC_RT_ID="rtb-0abc123..."]] --subnet-id [[PUBLIC_SUBNET_ID="subnet-0abc123..."]]
You should see an association ID in the output, confirming the link between your subnet and route table.
Before you verify, what do you expect to see when you describe this route table? Think about what routes should exist.
- Verify your route table configuration by running (replace the route table ID):
aws ec2 describe-route-tables --route-table-ids [[PUBLIC_RT_ID="rtb-0abc123..."]]
You should see two routes in the output. One with destination 10.0.0.0/16 targeting local (traffic within the VPC stays local). The second with destination 0.0.0.0/0 targeting your internet gateway ID.
Only seeing the local route?
- If you only see the 10.0.0.0/16 -> local route, the create-route command may have failed silently. Re-run it and check for typos in the route table ID or internet gateway ID.
- Make sure you used the route table ID (starts with rtb-) and the internet gateway ID (starts with igw-). Mixing up IDs is the most common mistake.
- help me fix my route table configuration
Your VPC now has a public subnet that can reach the internet. Next up, you will create a private subnet and discover what happens when there is no route to the outside world.
Add a Private Subnet and NAT Gateway
Your public subnet is live and routing traffic to the internet through the internet gateway you attached in the last step. Now it is time to build the private side of your network.
In production, databases and application servers live in private subnets with no direct internet access. You will create one now and see firsthand that it cannot reach the internet. That gap is exactly the problem a NAT Gateway solves.
In this step, get ready to:
- Create a private subnet with its own route table.
- Discover the connectivity gap in the private subnet's routing.
- Fix the gap by adding a NAT Gateway for outbound-only internet access.
Create the private subnet
Your VPC currently has one public subnet. You need a second subnet that is isolated from the public internet, where sensitive resources like databases would live.
- Create a private subnet in your VPC by running this command:
aws ec2 create-subnet --vpc-id [[VPC_ID="your VPC ID from Step 2"]] --cidr-block 10.0.2.0/24 --availability-zone us-east-1a --tag-specifications "ResourceType=subnet,Tags=[{Key=Name,Value=Private-Subnet}]" --query "Subnet.SubnetId" --output text
What does this command do?
- It creates a new subnet with the CIDR block 10.0.2.0/24 inside your VPC.
- The --query flag extracts just the subnet ID from the response so you can save it.
- This subnet uses a different CIDR range (10.0.2.0/24) from your public subnet (10.0.1.0/24), carving out a separate address block within the VPC.
- Save the subnet ID that the command returns. You will need it for the next commands.
- Create a private route table for this subnet by running:
aws ec2 create-route-table --vpc-id [[VPC_ID="your VPC ID from Step 2"]] --tag-specifications "ResourceType=route-table,Tags=[{Key=Name,Value=Private-RT}]" --query "RouteTable.RouteTableId" --output text
- Save the route table ID that the command returns.
- Associate the private subnet with this new route table by running:
aws ec2 associate-route-table --route-table-id [[PRIVATE_RT_ID="your Private-RT route table ID"]] --subnet-id [[PRIVATE_SUBNET_ID="your Private-Subnet subnet ID"]]
Why a separate route table?
Each subnet can have different routing rules. Your public subnet's route table (Public-RT) sends traffic to the internet gateway. The private subnet gets its own route table so you can control its traffic independently.
Seeing an InvalidParameterValue error?
- Double check that your VPC ID, subnet ID, and route table ID are all copied correctly with no extra spaces.
- Make sure you are using the Private-RT route table ID (not the Public-RT one from the previous step).
Help me troubleshoot this route table association error.
Examine the routing gap
Your private subnet now exists, but does it have any way to reach the internet? Before you look, think about what routes a brand-new route table has.
- Before you run the next command, predict: what routes do you think Private-RT contains right now?
- Check the private route table's routes by running:
aws ec2 describe-route-tables --route-table-ids [[PRIVATE_RT_ID="your Private-RT route table ID"]]
Look at the Routes section in the JSON output. You will see only one route:
{
"DestinationCidrBlock": "10.0.0.0/16",
"GatewayId": "local",
"Origin": "CreateRouteTable",
"State": "active"
}
What does this mean?
The only route is the local VPC route (10.0.0.0/16 targeting local). There is NO route to 0.0.0.0/0 anywhere.
Any instance placed in this subnet can talk to other resources inside the VPC, but it has zero internet access. It cannot download updates, reach external APIs, or do anything outside the VPC. This is the designed gap you will fix next.
Fix the gap with a NAT Gateway
A NAT Gateway gives private subnet resources outbound-only internet access. Traffic flows out through the NAT Gateway to the internet, but nothing from the internet can initiate a connection back in.
- Allocate an Elastic IP address for the NAT Gateway by running:
aws ec2 allocate-address --domain vpc --query "AllocationId" --output text
- Save the allocation ID that the command returns (it starts with eipalloc-).
- Create the NAT Gateway in your public subnet by running:
aws ec2 create-nat-gateway --subnet-id [[PUBLIC_SUBNET_ID="your Public-Subnet subnet ID"]] --allocation-id [[EIP_ALLOC_ID="your Elastic IP allocation ID"]] --tag-specifications "ResourceType=natgateway,Tags=[{Key=Name,Value=MyNATGateway}]" --query "NatGateway.NatGatewayId" --output text
Why is the NAT Gateway in the public subnet?
The NAT Gateway needs its own internet access (via the internet gateway) to forward traffic on behalf of private subnet resources. It sits in the public subnet so it can reach the internet, then private subnet traffic routes through it.
- Save the NAT Gateway ID that the command returns (it starts with nat-).
- The NAT Gateway takes 1-2 minutes to become available. Wait for it by running:
aws ec2 wait nat-gateway-available --nat-gateway-ids [[NAT_GW_ID="your NAT Gateway ID"]]
What does this command do?
The wait subcommand pauses your terminal until the NAT Gateway transitions from pending to available. You need it to be available before you can route traffic through it.
Cost reminder
The NAT Gateway costs $0.045/hour while it exists. You will delete it in the Before You Go section. If you step away, remember to come back and clean up.
- Once the wait command returns (your terminal shows a new prompt), add a default route from Private-RT to the NAT Gateway by running:
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 NAT Gateway ID"]]
You should see a response containing "Return": true confirming the route was created.
Seeing a NatGatewayNotFound error?
- Make sure the wait command has finished before running create-route. If you skipped the wait, the NAT Gateway may still be pending.
- Verify your NAT Gateway ID is correct by running aws ec2 describe-nat-gateways and checking the output.
Help me fix this NAT Gateway routing error.
- Before you verify, predict: what routes do you expect to see in Private-RT now?
- Confirm the fix worked by running:
aws ec2 describe-route-tables --route-table-ids [[PRIVATE_RT_ID="your Private-RT route table ID"]]
In the Routes section, you should now see two routes: the original local route (10.0.0.0/16 targeting local) and a new route with destination 0.0.0.0/0 targeting your NAT Gateway ID (nat-...).
Your private subnet now has outbound-only internet access through the NAT Gateway. Next up, you will lock down both subnets with security groups that control exactly which traffic can flow in and out at the instance level.
Secure the Network with Security Groups
Your VPC now has routing and internet access fully configured. But nothing is controlling which traffic can actually reach resources inside those subnets. Without a firewall layer, any resource you place in the network is wide open to any type of connection.
Security groups act as instance-level firewalls that control exactly which traffic is allowed in and out. In this step, you'll create two security groups that enforce the principle of least privilege: a web tier that accepts public HTTP and HTTPS traffic, and a database tier that only accepts connections from the web tier.
In this step, get ready to:
- Create a web server security group that allows HTTP and HTTPS traffic from the internet.
- Create a database security group that only accepts connections from the web tier.
- Verify that your security group rules enforce least privilege by inspecting the configuration.
Create a web server security group
A web server needs to accept HTTP and HTTPS traffic from the public internet. You'll create a security group specifically for that purpose, then add the inbound rules one at a time.
- 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 [[VPC_ID="your VPC ID from Step 2"]] --query "GroupId" --output text
What does this command do?
- It creates a new security group named WebServerSG inside your VPC.
- The --query "GroupId" --output text flags extract just the security group ID from the response, making it easy to copy.
- Copy the security group ID that is returned (it starts with sg-). You will need it for the next commands.
Seeing an InvalidGroup.Duplicate error?
This means a security group named WebServerSG already exists in your VPC. If you ran this command before, you can retrieve its ID by running:
aws ec2 describe-security-groups --filters "Name=group-name,Values=WebServerSG" --query "SecurityGroups[0].GroupId" --output text
Use the returned ID for the following commands. Help me fix my security group creation error.
Now you'll add two inbound rules that allow web traffic from anywhere on the internet.
- Allow inbound HTTP traffic (port 80) by running:
aws ec2 authorize-security-group-ingress --group-id [[WEB_SG_ID="your WebServerSG ID"]] --protocol tcp --port 80 --cidr 0.0.0.0/0
- You should see "Return": true in the JSON output, confirming the rule was added.
- Allow inbound HTTPS traffic (port 443) by running:
aws ec2 authorize-security-group-ingress --group-id [[WEB_SG_ID="your WebServerSG ID"]] --protocol tcp --port 443 --cidr 0.0.0.0/0
- Confirm "Return": true appears in the output again.
What is a security group?
A security group is a virtual firewall that controls inbound and outbound traffic for AWS resources. It is stateful, meaning if you allow traffic in, the response traffic is automatically allowed out.
The 0.0.0.0/0 CIDR block means "any IP address on the internet." For a web server, this is appropriate because anyone should be able to load your website.
Create a database security group with restricted access
A database should never be directly accessible from the internet. It should only accept connections from the web servers that need to query it. This is the principle of least privilege in action.
- Create the database security group by running:
aws ec2 create-security-group --group-name DBServerSG --description "Security group for database servers" --vpc-id [[VPC_ID="your VPC ID from Step 2"]] --query "GroupId" --output text
- Copy the returned security group ID (starts with sg-). This is your database security group ID.
- Allow inbound MySQL traffic (port 3306) ONLY from the web server security group by running:
aws ec2 authorize-security-group-ingress --group-id [[DB_SG_ID="your DBServerSG ID"]] --protocol tcp --port 3306 --source-group [[WEB_SG_ID="your WebServerSG ID"]]
- Confirm "Return": true appears in the response.
Why use a security group reference instead of a CIDR?
The --source-group flag references the web server security group by its ID. This means any resource attached to WebServerSG can reach the database, regardless of its IP address.
If you used a CIDR block instead, you would need to update the rule every time an IP address changed. The security group reference is dynamic and always stays correct.
Verify your security group configuration
Before you run the verification command, think about what you added to the database security group. Will the source be an IP range or a security group reference?
- Verify the database security group's inbound rules by running:
aws ec2 describe-security-groups --group-ids [[DB_SG_ID="your DBServerSG ID"]]
In the output, look for the IpPermissions section. You should see a rule with FromPort and ToPort both set to 3306.
The source is listed under UserIdGroupPairs with a GroupId matching your WebServerSG ID. There is no CidrIp value for this rule. This proves the database only accepts traffic from resources in the web tier.
- Now verify the web server security group by running:
aws ec2 describe-security-groups --group-ids [[WEB_SG_ID="your WebServerSG ID"]]
- Confirm you see two inbound rules: TCP port 80 from 0.0.0.0/0 and TCP port 443 from 0.0.0.0/0.
Missing rules in the output?
If IpPermissions is empty, the authorize commands may not have run successfully. Re-run them and check for "Return": true in the response.
Make sure you are using the correct security group ID (starts with sg-) in each command. A common mistake is pasting the VPC ID instead of the security group ID.
Help me troubleshoot my security group rules.
Your network now has two layers of firewall logic. The web tier is open to the public internet on ports 80 and 443. The database tier is locked down to only accept traffic from that web tier. Next up, you'll add subnet-level defense with Network ACLs and enable traffic monitoring with VPC Flow Logs.
Add Network ACLs and Enable VPC Flow Logs
Your VPC now has security groups protecting resources at the instance level. That is a solid start, but real-world compliance requires defense in depth.
In this step, you will add a subnet-level firewall (Network ACL) and enable VPC Flow Logs so you can monitor all traffic flowing through your network.
In this step, get ready to:
- Create a custom Network ACL with specific allow rules and associate it with your private subnet.
- Set up an IAM role and enable VPC Flow Logs to CloudWatch for traffic monitoring.
- Verify the complete architecture is wired together correctly.
Create a custom Network ACL for the private subnet
Security groups are stateful. They automatically allow return traffic. Network ACLs are stateless, meaning you must explicitly allow traffic in both directions.
- Create the custom NACL in your VPC by running this command:
aws ec2 create-network-acl --vpc-id [[VPC_ID="your VPC ID"]] --tag-specifications "ResourceType=network-acl,Tags=[{Key=Name,Value=Private-NACL}]" --query "NetworkAcl.NetworkAclId" --output text
What does this command do?
This creates a new Network ACL named Private-NACL inside your VPC. By default, a new custom NACL starts with deny-all rules for both inbound and outbound traffic. You will add specific allow rules next.
- Copy the NACL ID from the output (it looks like acl-0abc123...) and save it. You will use it in the next commands.
Now you need to add inbound rules. The private subnet hosts database servers, so the NACL should only allow MySQL traffic from the public subnet and return traffic on ephemeral ports.
- Allow inbound MySQL traffic from the public subnet by running:
aws ec2 create-network-acl-entry --network-acl-id [[NACL_ID="your NACL ID"]] --rule-number 100 --protocol tcp --port-range From=3306,To=3306 --cidr-block 10.0.1.0/24 --rule-action allow --ingress
What does rule 100 do?
Rule 100 allows inbound TCP traffic on port 3306 (MySQL) only from the public subnet's CIDR range 10.0.1.0/24. Traffic from any other source is denied by the default deny-all rule.
- Allow inbound ephemeral ports for return traffic by running:
aws ec2 create-network-acl-entry --network-acl-id [[NACL_ID="your NACL ID"]] --rule-number 200 --protocol tcp --port-range From=1024,To=65535 --cidr-block 0.0.0.0/0 --rule-action allow --ingress
Why allow ephemeral ports?
Because NACLs are stateless, return traffic from outbound connections arrives on ephemeral ports (1024-65535). Without this rule, your private subnet instances could not receive responses from the internet via the NAT Gateway.
- Allow all outbound traffic by running:
aws ec2 create-network-acl-entry --network-acl-id [[NACL_ID="your NACL ID"]] --rule-number 100 --protocol -1 --cidr-block 0.0.0.0/0 --rule-action allow --egress
What does --protocol -1 mean?
The value -1 means all protocols (TCP, UDP, ICMP, etc.). This outbound rule allows the private subnet to send traffic to any destination.
Your NACL has rules, but it is not protecting anything yet. You need to associate it with your private subnet, replacing the default NACL.
- Get the current NACL association ID for the private subnet by running:
aws ec2 describe-network-acls --filters "Name=association.subnet-id,Values=[[PRIVATE_SUBNET_ID="your Private-Subnet ID"]]" --query "NetworkAcls[0].Associations[?SubnetId=='[[PRIVATE_SUBNET_ID="your Private-Subnet ID"]]'].NetworkAclAssociationId" --output text
- Copy the association ID from the output (it looks like aclassoc-0abc123...).
- Replace the default NACL association with your custom NACL by running:
aws ec2 replace-network-acl-association --association-id [[ASSOC_ID="your NACL association ID"]] --network-acl-id [[NACL_ID="your NACL ID"]]
- Verify the NACL rules are in place by running:
aws ec2 describe-network-acls --network-acl-ids [[NACL_ID="your NACL ID"]]
You should see your three custom rules listed: inbound rule 100 (TCP 3306), inbound rule 200 (TCP 1024-65535), and outbound rule 100 (all traffic).
Not seeing your rules?
Make sure you are using the correct NACL ID in the describe command. If you see only the default deny-all rules (rule 32767), you may have created the entries on a different NACL.
help me troubleshoot my NACL rules not appearing in describe-network-acls
Enable VPC Flow Logs
Flow Logs record information about IP traffic going to and from your VPC. You will send these logs to CloudWatch so you can monitor and audit network activity.
- Create a CloudWatch Logs group to store your flow log data by running:
aws logs create-log-group --log-group-name VPC-Flow-Logs
Flow Logs need permission to write to CloudWatch. You will create an IAM role that allows the vpc-flow-logs.amazonaws.com service to publish logs.
- Create a trust policy file. In PowerShell, run:
@'
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "vpc-flow-logs.amazonaws.com"
},
"Action": "sts:AssumeRole"
}
]
}
'@ | Out-File -Encoding utf8 trust-policy.json
What is a trust policy?
A trust policy defines which AWS service is allowed to assume a role. This policy says the VPC Flow Logs service can use the role you are about to create, which gives it permission to write logs on your behalf.
- Create the IAM role using the trust policy by running:
aws iam create-role --role-name FlowLogsRole --assume-role-policy-document file://trust-policy.json
- Copy the Role ARN from the output (it looks like arn:aws:iam::123456789012:role/FlowLogsRole). Save it for the next command.
The role exists, but it has no permissions yet. You need to attach a permissions policy that allows writing to CloudWatch Logs.
- Create the permissions policy file by running:
@'
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"logs:CreateLogGroup",
"logs:CreateLogStream",
"logs:PutLogEvents",
"logs:DescribeLogGroups",
"logs:DescribeLogStreams"
],
"Resource": "*"
}
]
}
'@ | Out-File -Encoding utf8 permissions-policy.json
- Attach the permissions policy to the role by running:
aws iam put-role-policy --role-name FlowLogsRole --policy-name FlowLogsPermissions --policy-document file://permissions-policy.json
What permissions does this grant?
This inline policy allows the FlowLogsRole to create log streams and put log events in CloudWatch Logs. Without these permissions, Flow Logs would fail with an access error.
Now you can enable Flow Logs on your VPC.
- Enable VPC Flow Logs by running:
aws ec2 create-flow-logs --resource-type VPC --resource-ids [[VPC_ID="your VPC ID"]] --traffic-type ALL --log-destination-type cloud-watch-logs --log-group-name VPC-Flow-Logs --deliver-logs-permission-arn [[ROLE_ARN="your FlowLogsRole ARN"]]
What does this command do?
This creates a flow log on your entire VPC that captures ALL traffic (accepted and rejected). The logs are delivered to the VPC-Flow-Logs CloudWatch log group using the IAM role you just created.
- Verify the flow log is active by running:
aws ec2 describe-flow-logs --filter "Name=resource-id,Values=[[VPC_ID="your VPC ID"]]" --query "FlowLogs[0].FlowLogStatus" --output text
You should see ACTIVE in the output. This confirms your VPC is now logging all network traffic.
Seeing ACCESS_ERROR instead of ACTIVE?
This means the IAM role does not have the correct trust policy or permissions. Double-check that your trust policy file uses vpc-flow-logs.amazonaws.com as the service principal and that you attached the permissions policy with put-role-policy.
help me fix my VPC Flow Logs ACCESS_ERROR status
Verify the complete architecture
You have built every layer of the network. Before you finish, do a quick sweep to confirm everything is wired together.
- Describe all route tables in your VPC by running:
aws ec2 describe-route-tables --filters "Name=vpc-id,Values=[[VPC_ID="your VPC ID"]]"
- Confirm you see a Public-RT with a route to your internet gateway and a Private-RT with a route to your NAT Gateway.
- Describe all security groups in your VPC by running:
aws ec2 describe-security-groups --filters "Name=vpc-id,Values=[[VPC_ID="your VPC ID"]]"
- Confirm WebServerSG allows TCP 80 and 443 from 0.0.0.0/0, and DBServerSG allows TCP 3306 only from the WebServerSG group ID.
- Do a final Flow Logs check by running:
aws ec2 describe-flow-logs --filter "Name=resource-id,Values=[[VPC_ID="your VPC ID"]]"
Before you run this, what status do you expect to see for your flow log?
You should see FlowLogStatus showing ACTIVE. Your VPC is now fully secured with layered firewalls and auditable traffic logging.
Your multi-tier VPC is complete. You have built every component from scratch using the AWS CLI, exactly the way the BayNova team works day to day. Next up, tackle the Secret Mission to extend this architecture across two Availability Zones for high availability.
Secret mission
Add a Second Availability Zone for High Availability
Production federal networks require redundancy across multiple Availability Zones. Can you extend your VPC architecture to span two AZs, proving you can design for high availability?
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 costs approximately $0.045/hour while running, so leaving it active will accumulate charges.
Cost warning
Your NAT Gateway charges $0.045/hour plus $0.045/GB of data processed. If you completed the Secret Mission and created a second NAT Gateway, you are paying double. An unattached Elastic IP also incurs a small charge (~$0.005/hour). Delete or pause your resources within the hour to keep costs under $1.
Resources you used:
- NAT Gateway (MyNATGateway) and Elastic IP.
- NAT Gateway (MyNATGateway2) and Elastic IP 2 (if you completed the Secret Mission).
- VPC Flow Logs and CloudWatch Log Group (VPC-Flow-Logs).
- IAM Role (FlowLogsRole).
- VPC (MyVPC) with subnets, route tables, security groups, and Network ACL.
- IAM User with AdministratorAccess (used for CLI access).
Keep everything running
No action needed. Choose this if you are still actively building on this VPC or plan to launch EC2 instances into it soon.
- Your NAT Gateway will continue to charge ~$1.10/day (or ~$2.20/day if you have two).
- The VPC, subnets, route tables, security groups, and NACLs are free and will remain indefinitely.
- VPC Flow Logs will charge for CloudWatch log ingestion and storage beyond the always-free tier (5 GB ingestion, 5 GB storage per month).
Pause - I'll come back to this later
Shut down the resources that cost money while keeping the free VPC architecture intact. You can recreate the NAT Gateway later if needed.
- Delete your NAT Gateway(s) by running:
aws ec2 delete-nat-gateway --nat-gateway-id [[NAT_GW_ID="your NAT Gateway ID"]]
- Wait approximately 5 minutes for the NAT Gateway to fully delete.
- Release your Elastic IP(s) by running:
aws ec2 release-address --allocation-id [[EIP_ALLOC_ID="your Elastic IP allocation ID"]]
- If you completed the Secret Mission, repeat both commands for your second NAT Gateway and second Elastic IP.
- Everything else (VPC, subnets, route tables, security groups, NACLs, Flow Logs) is free and can remain indefinitely.
Delete - I don't want to use this again
Remove all project resources completely. Run these commands in order. Each depends on the previous resource being removed first.
Step 1: Delete the NAT Gateway(s) and release Elastic IPs.
aws ec2 delete-nat-gateway --nat-gateway-id [[NAT_GW_ID="your NAT Gateway ID"]]
- Wait approximately 5 minutes for the NAT Gateway to fully delete before continuing.
aws ec2 release-address --allocation-id [[EIP_ALLOC_ID="your Elastic IP allocation ID"]]
- If you completed the Secret Mission, repeat both commands for your second NAT Gateway and Elastic IP.
Step 2: Delete Flow Logs and the CloudWatch Log Group.
aws ec2 delete-flow-logs --flow-log-ids [[FLOW_LOG_ID="your Flow Log ID"]]
aws logs delete-log-group --log-group-name VPC-Flow-Logs
Step 3: Delete the IAM role used for Flow Logs.
- First, delete the inline policy attached to the role:
aws iam delete-role-policy --role-name FlowLogsRole --policy-name [[POLICY_NAME="your inline policy name"]]
- Then delete the role itself:
aws iam delete-role --role-name FlowLogsRole
Step 4: Delete security groups (delete DBServerSG first since it references WebServerSG).
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"]]
Step 5: Delete the custom Network ACL.
- The NACL must not be associated with any subnets. Since you are about to delete the subnets, first reassociate the private subnet with the default NACL, or simply delete the subnet first (which auto-removes the association). Delete the subnets first in Step 6, then return here.
Step 6: Delete all subnets.
aws ec2 delete-subnet --subnet-id [[PUB_SUBNET_ID="your Public-Subnet ID"]]
aws ec2 delete-subnet --subnet-id [[PRIV_SUBNET_ID="your Private-Subnet ID"]]
- If you completed the Secret Mission, also delete your second public and second private subnets:
aws ec2 delete-subnet --subnet-id [[PUB_SUBNET_2_ID="your Public-Subnet-2 ID"]]
aws ec2 delete-subnet --subnet-id [[PRIV_SUBNET_2_ID="your Private-Subnet-2 ID"]]
- Now delete the custom Network ACL:
aws ec2 delete-network-acl --network-acl-id [[NACL_ID="your Private-NACL ID"]]
Step 7: Disassociate and delete route tables.
- Find your route table association IDs:
aws ec2 describe-route-tables --route-table-ids [[PUB_RT_ID="your Public-RT ID"]] [[PRIV_RT_ID="your Private-RT ID"]]
- Disassociate each custom route table (replace with your association IDs from the output):
aws ec2 disassociate-route-table --association-id [[RT_ASSOC_ID="your route table association ID"]]
- Repeat for each association. Then delete the route tables:
aws ec2 delete-route-table --route-table-id [[PUB_RT_ID="your Public-RT ID"]]
aws ec2 delete-route-table --route-table-id [[PRIV_RT_ID="your Private-RT ID"]]
- If you completed the Secret Mission, also delete Private-RT-2:
aws ec2 delete-route-table --route-table-id [[PRIV_RT_2_ID="your Private-RT-2 ID"]]
Step 8: 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"]]
Step 9: Delete the VPC.
aws ec2 delete-vpc --vpc-id [[VPC_ID="your VPC ID"]]
- If the command succeeds, no output is returned. The default security group, default route table, and default NACL are deleted automatically with the VPC.
Step 10: (Optional) Delete the IAM user you created for CLI access.
- If you created an IAM user specifically for this project, you can delete it from the IAM console. Navigate to the IAM console, select **Users**, select your user, and click **Delete**.
Nice Work!
Nice Work!
You just built a production-grade, multi-tier VPC from scratch using nothing but the AWS CLI. Every subnet, route table, security group, and monitoring layer was configured by your commands.
You've learned how to:
- Build a multi-tier VPC with public and private subnets, an internet gateway for public access, and a NAT Gateway for outbound-only private access.
- Implement layered network security using Security Groups as instance-level firewalls and Network ACLs as subnet-level defense.
- Enable VPC Flow Logs to monitor all network traffic and support compliance auditing.
- Secret Mission: Extended the architecture across two Availability Zones for high availability.
Ready to quiz yourself?