Build a VPC with Public and Private Subnets
Build a production-style VPC with layered network security using the AWS CLI.
Introduction
30 Second Summary
Think about how a secure office building works. The lobby is open to visitors, but the server room in the basement requires a keycard, a security escort, and a locked door. Networks work the same way. Some resources need to face the internet, while others must stay completely hidden behind layers of protection.
In this project, you will build a production-style network on AWS entirely from the command line. You will create public subnets for internet-facing resources and private subnets for internal services, then prove the isolation works by connecting through a bastion host.
What You'll Build
A custom VPC with public and private subnets, security groups, and a bastion host architecture, all built using the AWS CLI.
By the end of this project, you'll have:
- A custom VPC with public and private subnets across two Availability Zones, built entirely via CLI commands.
- Security Groups and route tables that enforce least-privilege network access between your subnets.
- A working bastion host architecture that proves network isolation via SSH connectivity testing.
- Secret Mission: Configure custom Network ACLs that block traffic from specific IP ranges and verify the layered defense model.
Are there any prerequisites?
You need an AWS account with Free Tier active and basic familiarity with launching EC2 instances and connecting via SSH. If you have completed a beginner EC2 project before, you are ready for this one.
Install and Configure the AWS CLI
Every resource in this project is built from the command line. Instead of clicking through the AWS Management Console, you will use the AWS CLI to create your VPC, subnets, route tables, and security groups one by one.
In this step, you will install the AWS CLI on your Windows machine and connect it to your AWS account. Once configured, every command you run in PowerShell will execute directly against your AWS environment.
Why CLI instead of the console?
Building via CLI forces you to explicitly create and associate each networking component. The console wizard hides these relationships by handling them automatically behind the scenes.
CLI fluency also means you can script and repeat your infrastructure setup, which is a core skill for production environments.
In this step, get ready to:
- Install AWS CLI v2 on Windows.
- Verify the installation in PowerShell.
- Configure your AWS credentials and default region.
Download and install AWS CLI v2
AWS CLI v2 is the current version of Amazon's command-line tool. On Windows, it installs via a standard MSI installer.
- Download the AWS CLI v2 installer by opening this link in your browser:
https://awscli.amazonaws.com/AWSCLIV2.msi
- Open the downloaded AWSCLIV2.msi file.
- Click Next through each screen, accepting the default settings.
- Click Install when prompted.
- If Windows asks for administrator permissions, click Yes to allow the installation.
- Click Finish once the installation completes.
Verify the installation
You need to open a fresh PowerShell window so it picks up the new CLI path. Any PowerShell windows that were open during installation will not recognize the aws command.
- Press the Windows key to open your search bar.
- Type PowerShell and press Enter to open it.
- Check that the AWS CLI is installed by running this command:
aws --version
✔️ I see a version number
You should see output similar to aws-cli/2.x.x Python/3.x.x Windows/10. The exact version numbers may differ. The AWS CLI is ready to go.
ⓧ Command not found
If PowerShell does not recognize the aws command, try these fixes:
- Close all PowerShell windows and open a brand new one. The installer updates your system PATH, but existing windows do not pick up the change.
- If that does not work, restart your computer and try again.
- If you still see the error, re-download and re-run the MSI installer from https://awscli.amazonaws.com/AWSCLIV2.msi.
Configure your AWS credentials
The CLI needs to know which AWS account to talk to. The aws configure command stores your credentials locally so every future command authenticates automatically.
- Run the configuration command in PowerShell:
aws configure
The command prompts you for four values, one at a time. Enter each value and press Enter to move to the next prompt:
- AWS Access Key ID - paste your Access Key ID from the IAM console.
- AWS Secret Access Key - paste your Secret Access Key. This is the value you saved when you created the key pair in IAM.
- Default region name - type us-east-1.
- Default output format - type json.
What is an Access Key?
An IAM Access Key is a pair of credentials (an Access Key ID and a Secret Access Key) that lets programs authenticate with your AWS account. You create them in the IAM section of the AWS Console under your user's Security credentials tab.
If you have not created an Access Key yet, go to IAM in the AWS Console, select your user, click Security credentials, then Create access key. Save the Secret Access Key immediately because you cannot view it again.
Why us-east-1?
us-east-1 (N. Virginia) is the largest AWS region with the most services available. The t3.micro instance type is Free Tier eligible there. You can substitute another region, but every command in this project uses us-east-1 as the default.
- Confirm your configuration works by running this command:
aws sts get-caller-identity
You should see a JSON response with your account ID, user ARN, and user ID. This confirms the CLI can authenticate with your AWS account.
Seeing an authentication error?
If you see Unable to locate credentials or InvalidClientTokenId, check these common causes:
- Run aws configure again and double-check that you pasted the Access Key ID and Secret Access Key without extra spaces.
- Make sure you copied the Secret Access Key when you first created it. If you lost it, create a new Access Key in IAM and run aws configure again with the new values.
- Verify your IAM user has programmatic access enabled.
Help me troubleshoot my AWS CLI authentication error.
Your CLI is installed, configured, and authenticated. Next up, you will create your custom VPC and carve it into public and private subnets across two Availability Zones.
Create the VPC and Subnets
Your AWS CLI is configured and ready to talk to your account in us-east-1. Now it is time to build the foundation of your network architecture.
Every AWS resource you launch needs a network to live in. A VPC is your own isolated section of the AWS cloud where you control the IP address ranges, subnets, and routing. In this step, you will create a VPC and divide it into four subnets spread across two Availability Zones for high availability.
In this step, get ready to:
- Create a VPC with a custom CIDR block.
- Create four subnets across two Availability Zones.
- Tag all resources with descriptive names for identification.
Create your VPC
A VPC defines the overall IP address space for your network. You will use CIDR notation to reserve a block of 65,536 IP addresses that all your subnets will carve from.
- Create your VPC with the 10.0.0.0/16 CIDR block by running this command in PowerShell:
# Create the VPC with a /16 CIDR block (65,536 IP addresses)
aws ec2 create-vpc --cidr-block 10.0.0.0/16 --tag-specifications 'ResourceType=vpc,Tags=[{Key=Name,Value=nextwork-vpc}]'
What does this command do?
The --cidr-block 10.0.0.0/16 flag reserves the IP range from 10.0.0.0 to 10.0.255.255 for your VPC. The /16 means the first 16 bits are fixed, leaving the remaining bits for subnets and hosts.
The --tag-specifications flag assigns a Name tag at creation time so you can identify this VPC in the console.
- Look at the JSON output. Find the VpcId field and copy it somewhere accessible. It looks like vpc-0abc123def456. You will need this ID in every subsequent command.
What is CIDR notation?
CIDR stands for Classless Inter-Domain Routing. The number after the slash tells you how many bits of the IP address are fixed. A /16 gives you 65,536 addresses. A /24 gives you 256 addresses (251 usable, because AWS reserves 5 per subnet).
Think of it as slicing a pizza. The /16 is the whole pizza, and each /24 subnet is a slice with 251 usable pieces.
Seeing an error?
If you see An error occurred (UnauthorizedOperation), your IAM user may not have EC2 permissions. Go back to the IAM console and ensure your user has the AmazonEC2FullAccess policy attached.
If you see Unable to locate credentials, run aws configure again and re-enter your Access Key ID and Secret Access Key.
help me debug my create-vpc error
Create the public subnets
Subnets divide your VPC into smaller network segments. Public subnets will eventually have a route to the internet, while private subnets will remain isolated. You will spread them across two Availability Zones (us-east-1a and us-east-1b) so your architecture can survive if one data center has issues.
- Create your two public subnets by running these commands one at a time:
# Public subnet 1 in us-east-1a
aws ec2 create-subnet --vpc-id [[VPC_ID="your VPC ID e.g. vpc-0abc123def456"]] --cidr-block 10.0.1.0/24 --availability-zone us-east-1a --tag-specifications 'ResourceType=subnet,Tags=[{Key=Name,Value=nextwork-public-subnet-1}]'
# Public subnet 2 in us-east-1b
aws ec2 create-subnet --vpc-id [[VPC_ID="your VPC ID e.g. vpc-0abc123def456"]] --cidr-block 10.0.2.0/24 --availability-zone us-east-1b --tag-specifications 'ResourceType=subnet,Tags=[{Key=Name,Value=nextwork-public-subnet-2}]'
What do these commands do?
Each command creates one subnet inside your VPC. The --cidr-block carves a /24 slice (251 usable IPs) from your VPC's larger /16 block. The --availability-zone places each subnet in a different physical data center.
Subnet 10.0.1.0/24 lives in us-east-1a and subnet 10.0.2.0/24 lives in us-east-1b. Spreading subnets across zones means your application stays available even if one zone goes down.
- After each command, note the SubnetId from the JSON output. You will need these IDs in later steps.
Command returned an error?
If you see InvalidVpcID.NotFound, double-check that you pasted the correct VPC ID from the previous command. It must start with vpc-.
If you see InvalidParameterValue for the availability zone, confirm you are using us-east-1a and us-east-1b (not us-east-1 by itself).
help me fix my create-subnet error
Create the private subnets
Private subnets follow the same pattern but use a different CIDR range. Resources in these subnets will have no direct internet access, which is exactly what you want for databases and internal services.
- Create your two private subnets by running these commands one at a time:
# Private subnet 1 in us-east-1a
aws ec2 create-subnet --vpc-id [[VPC_ID="your VPC ID e.g. vpc-0abc123def456"]] --cidr-block 10.0.3.0/24 --availability-zone us-east-1a --tag-specifications 'ResourceType=subnet,Tags=[{Key=Name,Value=nextwork-private-subnet-1}]'
# Private subnet 2 in us-east-1b
aws ec2 create-subnet --vpc-id [[VPC_ID="your VPC ID e.g. vpc-0abc123def456"]] --cidr-block 10.0.4.0/24 --availability-zone us-east-1b --tag-specifications 'ResourceType=subnet,Tags=[{Key=Name,Value=nextwork-private-subnet-2}]'
Why separate public and private subnets?
This is the standard enterprise pattern for VPC design. Public subnets (10.0.1.0/24 and 10.0.2.0/24) hold resources that need to face the internet, like web servers or bastion hosts.
Private subnets (10.0.3.0/24 and 10.0.4.0/24) hold sensitive resources like databases and internal APIs. Keeping them off the internet is a core part of the AWS Well-Architected Framework security pillar.
- Note the SubnetId from each output. You now have four subnet IDs total.
- Verify all four subnets exist by running this command:
# List all subnets in your VPC
aws ec2 describe-subnets --filters "Name=vpc-id,Values=[[VPC_ID="your VPC ID e.g. vpc-0abc123def456"]]" --query "Subnets[*].[Tags[0].Value,CidrBlock,AvailabilityZone]" --output table
- You should see a table with four rows showing your subnet names, CIDR blocks, and availability zones.
Not seeing four subnets?
If fewer than four subnets appear, scroll up through your PowerShell history and check whether any create-subnet commands returned errors. Re-run any failed commands with the correct VPC ID.
If the table is empty, make sure you replaced the VPC ID placeholder in the describe-subnets filter with your actual VPC ID.
help me troubleshoot my missing subnets
Your network foundation is in place. You have a VPC with four subnets spread across two Availability Zones. Next up, you will connect the public subnets to the internet by configuring an Internet Gateway and route tables.
Configure Internet Gateway and Route Tables
Your VPC and four subnets are in place. You have the building blocks of a network, but right now nothing in your VPC can reach the internet.
An Internet Gateway is the component that connects your VPC to the outside world. In this step, you'll create one, then set up route tables that control which subnets get internet access and which stay completely isolated.
In this step, get ready to:
- Create and attach an Internet Gateway to your VPC.
- Build a custom route table with an internet route for public subnets.
- Verify that private subnets remain isolated with no internet route.
Create and attach an Internet Gateway
Without an Internet Gateway, even instances in a "public" subnet have no way to send or receive traffic from the outside world. The gateway is the bridge between your VPC and the internet.
- Create the Internet Gateway by running this command in PowerShell:
aws ec2 create-internet-gateway --tag-specifications "ResourceType=internet-gateway,Tags=[{Key=Name,Value=nextwork-igw}]"
You should see JSON output containing an InternetGatewayId field. Copy this ID for the next command.
- Attach the Internet Gateway to your VPC by running:
aws ec2 attach-internet-gateway --internet-gateway-id [[IGW_ID="your Internet Gateway ID (e.g. igw-0abc123def456)"]] --vpc-id [[VPC_ID="your VPC ID (e.g. vpc-0abc123def456)"]]
What does attaching do?
Attaching the gateway to your VPC makes internet communication possible, but it does not automatically give any subnet internet access. Traffic only flows through the gateway if a route table explicitly points to it.
- Confirm the attachment by running:
aws ec2 describe-internet-gateways --internet-gateway-ids [[IGW_ID="your Internet Gateway ID"]]
In the output, look for the Attachments array. It should show your VPC ID with a State of available.
Seeing an InvalidParameterValue error?
Double-check that you copied the full Internet Gateway ID (starts with igw-) and the full VPC ID (starts with vpc-). A missing character will cause this error.
If you see "Resource already attached," your gateway is already connected to the VPC. You can move on safely.
Help me troubleshoot my Internet Gateway attachment error.
Create a public route table with an internet route
Every subnet in a VPC is associated with a route table that determines where network traffic goes. Your VPC's main route table only routes traffic locally within the VPC. To make a subnet "public," you need a custom route table that sends internet-bound traffic to the Internet Gateway.
- Create a custom route table for your public subnets by running:
aws ec2 create-route-table --vpc-id [[VPC_ID="your VPC ID"]] --tag-specifications "ResourceType=route-table,Tags=[{Key=Name,Value=nextwork-public-rt}]"
You should see JSON output containing a RouteTableId field. Copy this ID for the next command.
- Add a route that sends all internet-bound traffic to the Internet Gateway by running:
aws ec2 create-route --route-table-id [[RTB_ID="your route table ID (e.g. rtb-0abc123def456)"]] --destination-cidr-block 0.0.0.0/0 --gateway-id [[IGW_ID="your Internet Gateway ID"]]
You should see "Return": true in the output, confirming the route was created successfully.
What is 0.0.0.0/0?
The destination 0.0.0.0/0 is a catch-all that matches any IP address not covered by a more specific route. Since your route table already has a local route for 10.0.0.0/16 (traffic within the VPC), this new route only applies to traffic heading outside the VPC.
Seeing a RouteAlreadyExists error?
This means the route table already has a 0.0.0.0/0 route. If you accidentally ran the command twice, you can move on. The route is already set up correctly.
If you see an InvalidRouteTableID error, confirm you copied the full route table ID (starts with rtb-) from the previous command's output.
Help me troubleshoot my route table configuration.
Associate public subnets and verify isolation
A route table does nothing until subnets are associated with it. Right now all four subnets still use the VPC's main route table, which has no internet route. You'll explicitly associate only the two public subnets with your new route table.
- Associate the first public subnet with the public route table by running:
aws ec2 associate-route-table --route-table-id [[RTB_ID="your public route table ID"]] --subnet-id [[PUBLIC_SUBNET_1_ID="your public subnet 1 ID (e.g. subnet-0abc123)"]]
- Associate the second public subnet with the public route table by running:
aws ec2 associate-route-table --route-table-id [[RTB_ID="your public route table ID"]] --subnet-id [[PUBLIC_SUBNET_2_ID="your public subnet 2 ID (e.g. subnet-0def456)"]]
Each command returns an AssociationId, confirming the subnet is now linked to the public route table.
Why do private subnets stay isolated?
You only associated the two public subnets with the new route table. The two private subnets remain on the VPC's main route table, which has no 0.0.0.0/0 route. This means private subnets cannot send or receive traffic from the internet, even if an instance in them wanted to.
This is the core of network isolation in AWS. A subnet is only "public" because its route table has a path to an Internet Gateway.
- Verify the private subnets have no internet route by inspecting the main route table:
aws ec2 describe-route-tables --filters "Name=association.main,Values=true" "Name=vpc-id,Values=[[VPC_ID=\"your VPC ID\"]]"
In the output, look at the Routes array. You should see only a single route with DestinationCidrBlock of 10.0.0.0/16 and a target of local. No 0.0.0.0/0 route means no internet access for your private subnets.
Seeing a 0.0.0.0/0 route in the main route table?
If the main route table shows an internet route, you may have accidentally added the route to the main table instead of your custom nextwork-public-rt table. Delete the route from the main table by running aws ec2 delete-route with the main route table ID and --destination-cidr-block 0.0.0.0/0, then re-run the create-route command with the correct route table ID.
Help me fix my route table configuration so private subnets are isolated.
Your network routing is complete. Public subnets have a path to the internet through the Internet Gateway, while private subnets are fully isolated with only local VPC traffic allowed. Next up, you'll configure Security Groups to control exactly which traffic is allowed to reach your instances.
Configure Security Groups
Your route tables are set up to enforce public and private network boundaries. Traffic in the public subnets can reach the internet, while private subnets remain isolated.
Route tables control where traffic goes, but they do not control who is allowed to send that traffic. Security Groups are virtual firewalls that control which specific traffic can reach your EC2 instances. In this step, you will create two Security Groups that implement least-privilege access for a bastion host architecture.
In this step, get ready to:
- Create a bastion Security Group allowing SSH only from your IP address.
- Create a private instance Security Group allowing SSH only from the bastion Security Group.
- Verify both Security Groups have correct inbound and outbound rules.
Create the bastion Security Group
The bastion host is the only instance that will accept SSH connections directly from the internet. Its Security Group must allow SSH traffic from your IP address only.
- First, retrieve your VPC ID by running this command:
$VPC_ID = aws ec2 describe-vpcs `
--filters "Name=tag:Name,Values=nextwork-vpc" `
--query "Vpcs[0].VpcId" `
--output text
echo $VPC_ID
What does this command do?
This retrieves the VPC ID for your nextwork-vpc using a tag filter. The --query flag extracts just the ID from the JSON response. You need the VPC ID to create Security Groups inside your VPC.
- You should see a value starting with vpc- printed to the terminal.
- Next, find your public IP address by running this command:
$MY_IP = (Invoke-RestMethod -Uri "https://checkip.amazonaws.com").Trim()
echo $MY_IP
Why do I need my IP address?
Security Groups use CIDR notation to specify which IP addresses are allowed. By using your own IP with a /32 suffix, you restrict SSH access to only your machine. This follows the principle of least privilege.
- You should see your public IP address printed (e.g., 203.0.113.25).
- Create the bastion Security Group in your VPC by running this command:
$BASTION_SG_ID = aws ec2 create-security-group `
--group-name nextwork-bastion-sg `
--description "Security group for bastion host - SSH access" `
--vpc-id $VPC_ID `
--query "GroupId" `
--output text
echo $BASTION_SG_ID
- You should see a value starting with sg- printed to the terminal.
- Tag the Security Group by running:
aws ec2 create-tags `
--resources $BASTION_SG_ID `
--tags Key=Name,Value=nextwork-bastion-sg
- Add the inbound SSH rule that allows connections from your IP address only by running:
aws ec2 authorize-security-group-ingress `
--group-id $BASTION_SG_ID `
--protocol tcp `
--port 22 `
--cidr "$MY_IP/32"
What does this rule do?
This allows inbound TCP traffic on port 22 (SSH) from your IP address only. The /32 means a single IP address. No other IP on the internet can reach instances using this Security Group.
The default outbound rule already allows all outbound traffic, so you do not need to add one.
- You should see JSON output with "Return": true confirming the rule was added.
Seeing an error about invalid CIDR?
Make sure your $MY_IP variable contains a valid IP address with no extra whitespace or newlines. Run echo $MY_IP to check. If it looks wrong, re-run the Invoke-RestMethod command.
help me fix my Security Group ingress rule CIDR error
Create the private instance Security Group
The private instance should only accept SSH connections from the bastion host. Instead of hardcoding the bastion's IP address (which changes if the instance restarts), you will reference the bastion's Security Group directly. This is an AWS best practice called security group referencing.
- Create the private instance Security Group by running:
$PRIVATE_SG_ID = aws ec2 create-security-group `
--group-name nextwork-private-sg `
--description "Security group for private instances - bastion access only" `
--vpc-id $VPC_ID `
--query "GroupId" `
--output text
echo $PRIVATE_SG_ID
- You should see another sg- value printed.
- Tag the Security Group by running:
aws ec2 create-tags `
--resources $PRIVATE_SG_ID `
--tags Key=Name,Value=nextwork-private-sg
- Add the inbound SSH rule that references the bastion Security Group by running:
aws ec2 authorize-security-group-ingress `
--group-id $PRIVATE_SG_ID `
--protocol tcp `
--port 22 `
--source-group $BASTION_SG_ID
What is security group referencing?
The --source-group flag tells AWS to allow traffic from any instance that belongs to the bastion Security Group. This is more resilient than using an IP address because it works even if the bastion's IP changes after a restart.
Traffic is allowed based on the private IP addresses of instances associated with the source Security Group.
- You should see JSON output with "Return": true confirming the rule was added.
Getting InvalidGroup.NotFound?
Make sure your $BASTION_SG_ID variable is still set. If you restarted PowerShell between steps, the variable will be empty. Run echo $BASTION_SG_ID to check. If it is blank, describe your Security Groups and find the ID manually.
help me fix my source-group Security Group reference error
Verify both Security Groups
Before moving on, confirm that both Security Groups have the correct inbound rules and the default allow-all outbound rule.
- Describe both Security Groups by running:
aws ec2 describe-security-groups `
--group-ids $BASTION_SG_ID $PRIVATE_SG_ID
What should I see?
In the JSON output, look for two Security Groups. The bastion group (nextwork-bastion-sg) should have an IpPermissions entry showing TCP port 22 with your IP in CidrIp.
The private group (nextwork-private-sg) should have an IpPermissions entry showing TCP port 22 with UserIdGroupPairs containing the bastion Security Group ID.
Both groups should have IpPermissionsEgress with protocol -1 (all traffic), meaning all outbound traffic is allowed by default.
Save your Security Group IDs
You will need both $BASTION_SG_ID and $PRIVATE_SG_ID in the next step when launching EC2 instances. Keep your PowerShell session open, or note down the IDs.
Your Security Groups are locked down and ready. Next up, you will launch EC2 instances into the public and private subnets and prove the network isolation works by SSH-ing through the bastion host.
Launch EC2 Instances and Test Connectivity
Your network is fully wired. The VPC has public and private subnets, an Internet Gateway with route tables directing traffic, and Security Groups enforcing who can talk to whom.
Now you will put this architecture to the test. You will launch two EC2 instances and prove that the private instance is truly isolated from the internet, reachable only through the bastion host in the public subnet.
In this step, get ready to:
- Create an EC2 key pair for SSH authentication.
- Launch a bastion host in the public subnet and an application server in the private subnet.
- SSH into the bastion, then hop to the private instance using SSH agent forwarding to prove network isolation.
Create a key pair for SSH access
EC2 instances use key pairs for authentication instead of passwords. You create the key pair once and use it for both instances.
- Create the key pair and save the private key to a file by running this command:
aws ec2 create-key-pair --key-name nextwork-keypair --query "KeyMaterial" --output text | out-file -encoding ascii -filepath nextwork-keypair.pem
Why out-file with ascii encoding?
PowerShell defaults to UTF-16 encoding when redirecting output. SSH clients cannot read UTF-16 key files. The -encoding ascii flag ensures the key is saved in a format OpenSSH can use.
- Lock down the key file permissions so only your user account can read it by running these commands:
icacls .\nextwork-keypair.pem /inheritance:r
icacls .\nextwork-keypair.pem /grant:r "$($env:USERNAME):(R)"
What do these commands do?
The first command removes all inherited permissions from the file. The second grants read-only access to your Windows user account exclusively.
OpenSSH refuses to use private keys that other accounts can access. These commands are the Windows equivalent of chmod 400 on macOS or Linux.
- Verify the key pair exists in AWS by running:
aws ec2 describe-key-pairs --key-names nextwork-keypair
You should see JSON output showing nextwork-keypair with a fingerprint value.
Seeing an error about duplicate key names?
If a key pair named nextwork-keypair already exists from a previous attempt, delete it first with aws ec2 delete-key-pair --key-name nextwork-keypair and then re-run the create command.
Help me fix a key pair creation error.
Launch the bastion host and application server
You will launch two t3.micro instances. The bastion goes in the public subnet with a public IP. The application server goes in the private subnet with no public IP.
Instead of looking up an AMI ID manually, you will use an SSM parameter that always resolves to the latest Amazon Linux 2023 image.
- First, store your VPC ID and subnet IDs in variables so the launch commands stay readable. Run these commands:
# Store the VPC ID
$VPC_ID = aws ec2 describe-vpcs --filters "Name=tag:Name,Values=nextwork-vpc" --query "Vpcs[0].VpcId" --output text
# Store the public subnet ID
$PUBLIC_SUBNET = aws ec2 describe-subnets --filters "Name=tag:Name,Values=nextwork-public-subnet-1" --query "Subnets[0].SubnetId" --output text
# Store the private subnet ID
$PRIVATE_SUBNET = aws ec2 describe-subnets --filters "Name=tag:Name,Values=nextwork-private-subnet-1" --query "Subnets[0].SubnetId" --output text
# Store the bastion security group ID
$BASTION_SG = aws ec2 describe-security-groups --filters "Name=tag:Name,Values=nextwork-bastion-sg" --query "SecurityGroups[0].GroupId" --output text
# Store the private security group ID
$PRIVATE_SG = aws ec2 describe-security-groups --filters "Name=tag:Name,Values=nextwork-private-sg" --query "SecurityGroups[0].GroupId" --output text
What does this code do?
Each line queries AWS for a resource by its Name tag and stores the resulting ID in a PowerShell variable. These variables keep the upcoming launch commands short and readable.
- Launch the bastion host in the public subnet by running this command:
aws ec2 run-instances `
--image-id resolve:ssm:/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64 `
--instance-type t3.micro `
--subnet-id $PUBLIC_SUBNET `
--security-group-ids $BASTION_SG `
--key-name nextwork-keypair `
--associate-public-ip-address `
--tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=nextwork-bastion}]'
What does this command do?
- resolve:ssm:/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64 tells AWS to look up the latest Amazon Linux 2023 AMI at launch time, so you never need a hardcoded AMI ID.
- --associate-public-ip-address gives the bastion a public IP so you can SSH to it from the internet.
- --security-group-ids attaches the bastion Security Group, which only allows SSH from your IP.
- Launch the application server in the private subnet by running this command:
aws ec2 run-instances `
--image-id resolve:ssm:/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64 `
--instance-type t3.micro `
--subnet-id $PRIVATE_SUBNET `
--security-group-ids $PRIVATE_SG `
--key-name nextwork-keypair `
--no-associate-public-ip-address `
--tag-specifications 'ResourceType=instance,Tags=[{Key=Name,Value=nextwork-app-server}]'
What is different about this instance?
The --no-associate-public-ip-address flag means this instance gets no public IP. It sits in the private subnet with no internet route. The only way to reach it is through another instance inside the VPC.
- Wait about 30 seconds for both instances to start, then confirm they are running:
aws ec2 describe-instances `
--filters "Name=tag:Name,Values=nextwork-bastion,nextwork-app-server" "Name=instance-state-name,Values=running" `
--query "Reservations[].Instances[].[Tags[?Key=='Name'].Value | [0], InstanceId, State.Name, PublicIpAddress, PrivateIpAddress]" `
--output table
You should see a table with two rows. The bastion has both a public and private IP. The app server has only a private IP (the public IP column shows None).
Only seeing one instance or none?
Instances take 30-60 seconds to reach the running state. Wait a moment and run the describe command again.
If an instance shows terminated immediately, check that your subnet IDs and security group IDs are correct by echoing the variables: echo $PUBLIC_SUBNET.
Help me troubleshoot my EC2 instance launch.
Test connectivity with SSH agent forwarding
The final test proves your architecture works. You will SSH into the bastion from your local machine, then jump from the bastion to the private instance without copying your key file onto the bastion.
This technique is called SSH agent forwarding. It lets the bastion use your local machine's key to authenticate to the private instance, keeping the key secure on your machine.
- First, grab the bastion's public IP and the app server's private IP by running these commands:
# Get the bastion's public IP
$BASTION_IP = aws ec2 describe-instances --filters "Name=tag:Name,Values=nextwork-bastion" "Name=instance-state-name,Values=running" --query "Reservations[0].Instances[0].PublicIpAddress" --output text
# Get the app server's private IP
$APP_IP = aws ec2 describe-instances --filters "Name=tag:Name,Values=nextwork-app-server" "Name=instance-state-name,Values=running" --query "Reservations[0].Instances[0].PrivateIpAddress" --output text
# Print both IPs so you can see them
echo "Bastion public IP: $BASTION_IP"
echo "App server private IP: $APP_IP"
What should I see?
You should see two IP addresses printed. The bastion IP is a public address (something like 54.x.x.x). The app server IP is a private address starting with 10.0.3. because it is in the 10.0.3.0/24 subnet.
- Start the SSH agent and add your key to it by running these commands:
# Start the ssh-agent service
Get-Service ssh-agent | Set-Service -StartupType Manual
Start-Service ssh-agent
# Add your private key to the agent
ssh-add .\nextwork-keypair.pem
What does ssh-agent do?
The SSH agent holds your private key in memory. When you connect to the bastion with the -A flag, the agent forwards authentication requests from the bastion back to your local machine. Your key never leaves your computer.
Seeing a permissions error when adding the key?
If you see Permissions for 'nextwork-keypair.pem' are too open, re-run the icacls commands from earlier to lock down the file permissions.
If the ssh-agent service fails to start, open PowerShell as Administrator and run the Get-Service and Start-Service commands from there.
Help me fix SSH agent issues on Windows.
- SSH into the bastion host with agent forwarding enabled by running:
ssh -A ec2-user@$BASTION_IP
- The first time you connect, SSH will ask you to verify the host fingerprint. Type yes and press Enter.
You should see the Amazon Linux 2023 prompt. Your terminal now shows something like [ec2-user@ip-10-0-1-x ~]$. You are inside the bastion.
- From inside the bastion, SSH to the private application server. Replace the IP below with the private IP you noted earlier:
ssh ec2-user@[[APP_PRIVATE_IP="your app server private IP, e.g. 10.0.3.x"]]
- Type yes again when prompted to verify the host fingerprint.
You should see a second Amazon Linux 2023 prompt, now showing [ec2-user@ip-10-0-3-x ~]$. You are inside the private instance.
What just happened?
You proved that the private instance has no public IP and no internet route. The only way to reach it was through the bastion in the public subnet.
The -A flag forwarded your local SSH key to the bastion, which used it to authenticate to the private instance. Your key never touched the bastion's disk.
- Exit back to the bastion by typing:
exit
- Exit the bastion to return to your local PowerShell by typing:
exit
Connection timed out when SSH-ing to the bastion?
A timeout means network traffic is not reaching the instance. Check these common causes:
- Your IP may have changed since you created the bastion Security Group. Run curl ifconfig.me to check your current public IP and update the Security Group inbound rule if needed.
- Confirm the bastion instance is in the public subnet with a public IP assigned.
- Confirm the public subnet's route table has a 0.0.0.0/0 route pointing to the Internet Gateway.
Help me troubleshoot SSH connectivity to my bastion host.
Connection refused when hopping to the private instance?
If you can reach the bastion but not the private instance, check:
- The private Security Group must allow SSH from the bastion Security Group (not the bastion's IP).
- Make sure ssh-add -l on the bastion shows your key. If not, you may have forgotten the -A flag when connecting to the bastion.
Help me debug SSH from the bastion to the private instance.
You have proven that your VPC architecture works. The private subnet is genuinely isolated from the internet, and the bastion host pattern gives you controlled access. Next up, you will take this security a step further with custom Network ACLs in the Secret Mission.
Secret mission
Implement Custom Network ACLs
Your VPC has Security Groups protecting each instance, but production environments need a second layer of defense. In this secret mission, you'll build a custom Network ACL that explicitly blocks traffic from a simulated malicious IP range while keeping legitimate bastion traffic flowing. This teaches the layered defense model that AWS Well-Architected recommends.
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 EC2 instances in this project are covered by Free Tier credits (t3.micro), but those credits expire after 6 months.
Cost warning
Your two t3.micro instances consume Free Tier credits while running. If you leave them running 24/7, they use about 1,440 instance-hours per month combined. Once your 6-month Free Tier credits are depleted, you will be charged standard EC2 rates.
If you are not actively using these instances, stop them or delete them to preserve your credits for other projects.
Resources you used:
- 1 VPC (free).
- 4 subnets (free).
- 1 Internet Gateway (free).
- 2 route tables (free).
- 2 Security Groups (free).
- 2 EC2 instances (t3.micro, covered by Free Tier credits while credits last).
- 1 Key Pair (free).
Keep everything running
No action needed. Choose this if you are still actively building or plan to continue with the next project in this series.
- Both t3.micro instances combined use less than 750 hours/month each. You can keep them running as long as you have Free Tier credits remaining.
- All networking resources (VPC, subnets, Internet Gateway, route tables, Security Groups) are free and do not consume credits.
Pause - I'll come back to this later
Stop both EC2 instances to preserve your Free Tier credits. Stopped instances do not consume instance-hour credits. You still retain all networking configuration.
- Stop the bastion host and private instance by running:
aws ec2 stop-instances --instance-ids [[BASTION_INSTANCE_ID="your bastion instance ID"]] [[PRIVATE_INSTANCE_ID="your private instance ID"]]
- Your VPC, subnets, Internet Gateway, route tables, and Security Groups remain in place at no cost.
- When you are ready to resume, start the instances again with aws ec2 start-instances.
- Note that your bastion host will receive a new public IP address when restarted. Run aws ec2 describe-instances to find the new IP.
Delete - I don't want to use this again
Remove all project resources and start fresh. Run these commands in order, since some resources depend on others being deleted first.
- Terminate both EC2 instances by running:
aws ec2 terminate-instances --instance-ids [[BASTION_INSTANCE_ID="your bastion instance ID"]] [[PRIVATE_INSTANCE_ID="your private instance ID"]]
- Wait for instances to fully terminate (about 1-2 minutes), then delete the Security Groups:
aws ec2 delete-security-group --group-id [[BASTION_SG_ID="your bastion security group ID"]]
aws ec2 delete-security-group --group-id [[PRIVATE_SG_ID="your private instance security group ID"]]
- Delete the subnets:
aws ec2 delete-subnet --subnet-id [[PUBLIC_SUBNET_1_ID="your first public subnet ID"]]
aws ec2 delete-subnet --subnet-id [[PUBLIC_SUBNET_2_ID="your second public subnet ID"]]
aws ec2 delete-subnet --subnet-id [[PRIVATE_SUBNET_1_ID="your first private subnet ID"]]
aws ec2 delete-subnet --subnet-id [[PRIVATE_SUBNET_2_ID="your second private subnet ID"]]
- Disassociate and delete the custom route table:
aws ec2 delete-route-table --route-table-id [[PUBLIC_RTB_ID="your public 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"]]
- Finally, delete the VPC:
aws ec2 delete-vpc --vpc-id [[VPC_ID="your VPC ID"]]
- Optionally, delete the key pair if you do not plan to use it for other projects:
aws ec2 delete-key-pair --key-name [[KEY_NAME="your key pair name"]]
- Delete the .pem file from your local machine as well.
That's a Wrap!
That's a Wrap!
Nice work! You just built a production-style VPC network entirely from the command line.
You've learned how to:
- Built a multi-AZ VPC with public and private subnets, an Internet Gateway, and custom route tables using the AWS CLI.
- Configured Security Groups with least-privilege access and security group referencing to control traffic between layers.
- Deployed a bastion host architecture and proved network isolation by SSH-ing through public to private subnets.
- Secret Mission: Implemented custom Network ACLs with explicit deny rules and verified the layered defense model.
Ready to quiz yourself?