Build a small VPC: public and private subnets, a security group and a network ACL

short · 35 min · Task statement 3.5

Task

Build an Amazon VPC with two subnets in different Availability Zones, make one of them public with an internet gateway and a route table, and leave the other private. Add a security group that allows HTTPS from inside the VPC only, and a network ACL with a numbered deny rule and a numbered allow rule. Then look at Route 53 and AWS Direct Connect without creating anything. At the end you can show what makes a subnet public, and how a stateful, allow-only security group differs from a stateless network ACL that can deny.

Steps

  1. Pick two Availability Zones in your Region and keep their names in shell variables:
AZ1=$(aws ec2 describe-availability-zones --filters Name=zone-type,Values=availability-zone --query 'AvailabilityZones[0].ZoneName' --output text)
AZ2=$(aws ec2 describe-availability-zones --filters Name=zone-type,Values=availability-zone --query 'AvailabilityZones[1].ZoneName' --output text)
echo $AZ1 $AZ2

It prints two names, such as us-east-1a us-east-1b. 2. Create the VPC with the private address range 10.20.0.0/16, and one subnet in each zone. A subnet lives entirely in one Availability Zone:

VPC_ID=$(aws ec2 create-vpc --cidr-block 10.20.0.0/16 --tag-specifications 'ResourceType=vpc,Tags=[{Key=Name,Value=clf-lab17-vpc}]' --query 'Vpc.VpcId' --output text)
PUB_ID=$(aws ec2 create-subnet --vpc-id $VPC_ID --cidr-block 10.20.1.0/24 --availability-zone $AZ1 --tag-specifications 'ResourceType=subnet,Tags=[{Key=Name,Value=clf-lab17-public}]' --query 'Subnet.SubnetId' --output text)
PRIV_ID=$(aws ec2 create-subnet --vpc-id $VPC_ID --cidr-block 10.20.2.0/24 --availability-zone $AZ2 --tag-specifications 'ResourceType=subnet,Tags=[{Key=Name,Value=clf-lab17-private}]' --query 'Subnet.SubnetId' --output text)
echo $VPC_ID $PUB_ID $PRIV_ID

Both subnets are private at this point: nothing yet gives either of them a way to the internet. 3. Make the first subnet public. Create an internet gateway, attach it to the VPC, create a route table with a route to it, and associate that route table with clf-lab17-public only:

IGW_ID=$(aws ec2 create-internet-gateway --tag-specifications 'ResourceType=internet-gateway,Tags=[{Key=Name,Value=clf-lab17-igw}]' --query 'InternetGateway.InternetGatewayId' --output text)
aws ec2 attach-internet-gateway --internet-gateway-id $IGW_ID --vpc-id $VPC_ID
RTB_ID=$(aws ec2 create-route-table --vpc-id $VPC_ID --tag-specifications 'ResourceType=route-table,Tags=[{Key=Name,Value=clf-lab17-public-rt}]' --query 'RouteTable.RouteTableId' --output text)
aws ec2 create-route --route-table-id $RTB_ID --destination-cidr-block 0.0.0.0/0 --gateway-id $IGW_ID
aws ec2 associate-route-table --route-table-id $RTB_ID --subnet-id $PUB_ID

The destination 0.0.0.0/0 means every address outside the VPC. That one route is the whole difference between the two subnets: clf-lab17-private still uses the VPC's main route table, which only knows the VPC's own range. 4. Create a security group that allows HTTPS in from inside the VPC, and read its rules back:

SG_ID=$(aws ec2 create-security-group --group-name clf-lab17-web-sg --description "HTTPS from inside the lab VPC only" --vpc-id $VPC_ID --tag-specifications 'ResourceType=security-group,Tags=[{Key=Name,Value=clf-lab17-web-sg}]' --query 'GroupId' --output text)
aws ec2 authorize-security-group-ingress --group-id $SG_ID --protocol tcp --port 443 --cidr 10.20.0.0/16
aws ec2 describe-security-groups --group-ids $SG_ID --query 'SecurityGroups[0].[IpPermissions,IpPermissionsEgress]'

There is one inbound rule, the one you added, and one outbound rule that AWS adds to every new group and that lets everything leave. Every rule is an allow rule: a security group has no way to block a single address. Because it is stateful, the replies to allowed HTTPS requests go out without an outbound rule of their own. 5. Create a network ACL and give it two numbered inbound rules: rule 100 denies a range you want to block (203.0.113.0/24, a range reserved for documentation), and rule 200 allows HTTPS from inside the VPC:

ACL_ID=$(aws ec2 create-network-acl --vpc-id $VPC_ID --tag-specifications 'ResourceType=network-acl,Tags=[{Key=Name,Value=clf-lab17-nacl}]' --query 'NetworkAcl.NetworkAclId' --output text)
aws ec2 create-network-acl-entry --network-acl-id $ACL_ID --ingress --rule-number 100 --protocol -1 --cidr-block 203.0.113.0/24 --rule-action deny
aws ec2 create-network-acl-entry --network-acl-id $ACL_ID --ingress --rule-number 200 --protocol tcp --port-range From=443,To=443 --cidr-block 10.20.0.0/16 --rule-action allow
aws ec2 describe-network-acls --network-acl-ids $ACL_ID --query 'NetworkAcls[0].Entries[].[Egress,RuleNumber,RuleAction,CidrBlock]' --output table

The table shows your two inbound rules, plus a final rule numbered 32767 in each direction that denies whatever no lower-numbered rule matched. Rules are read lowest number first. Look at the outbound side: it has only that final deny. Because a network ACL is stateless, the replies to the HTTPS allowed by rule 200 would be dropped until you added an outbound rule. The ACL is not associated with either subnet, so it changes no traffic. 6. Look at the rest of the lesson's network services, read-only. Route 53 is the DNS service, and Direct Connect is a dedicated line ordered at a physical location:

aws route53 list-hosted-zones --query 'length(HostedZones)'
aws directconnect describe-locations --query 'locations[].locationName' --output text

The first prints how many hosted zones the account has, 0 if you have never registered or hosted a domain. The second lists the Direct Connect locations that serve your Region. A hosted zone is charged each month, and a Direct Connect connection is a physical circuit, so this lab only reads. 7. Open the VPC console, choose Your VPCs, and select clf-lab17-vpc. The Resource map tab draws the VPC, its two subnets in their zones, the route tables and the internet gateway, and shows that only clf-lab17-public is connected to clf-lab17-igw.

Verify

Run these read-only checks. They find each resource by its name tag, so they work even in a new CloudShell session:

aws ec2 describe-subnets --filters Name=tag:Name,Values=clf-lab17-public,clf-lab17-private --query 'length(Subnets)'

Expected output: 2.

aws ec2 describe-route-tables --filters Name=tag:Name,Values=clf-lab17-public-rt --query "RouteTables[0].Routes[?DestinationCidrBlock=='0.0.0.0/0'] | length(@)"

Expected output: 1, the route to the internet gateway that makes the subnet public.

aws ec2 describe-security-groups --filters Name=group-name,Values=clf-lab17-web-sg --query 'SecurityGroups[0].IpPermissions[0].FromPort'

Expected output: 443.

aws ec2 describe-network-acls --filters Name=tag:Name,Values=clf-lab17-nacl --query "NetworkAcls[0].Entries[?CidrBlock=='203.0.113.0/24'].RuleAction" --output text

Expected output: deny.

Cost and clean-up

Everything this lab creates is free, so no charge is expected. Charges would start if you added a NAT gateway, an EC2 instance, an Elastic IP or any public IPv4 address to the VPC, which this lab never does. The VPC pricing page and the AWS Pricing Calculator list what is charged. Clean up anyway, so that an empty network does not sit in the account waiting for something to be put in it.

Created by this lab: the VPC clf-lab17-vpc; the subnets clf-lab17-public and clf-lab17-private; the internet gateway clf-lab17-igw, attached to the VPC; the route table clf-lab17-public-rt, with its route to the internet gateway and its association with the public subnet; the security group clf-lab17-web-sg; the network ACL clf-lab17-nacl, with its rules 100 and 200.

  1. Look every resource up again by its name tag, so the clean-up works even if CloudShell has restarted:
VPC_ID=$(aws ec2 describe-vpcs --filters Name=tag:Name,Values=clf-lab17-vpc --query 'Vpcs[0].VpcId' --output text)
PUB_ID=$(aws ec2 describe-subnets --filters Name=tag:Name,Values=clf-lab17-public --query 'Subnets[0].SubnetId' --output text)
PRIV_ID=$(aws ec2 describe-subnets --filters Name=tag:Name,Values=clf-lab17-private --query 'Subnets[0].SubnetId' --output text)
IGW_ID=$(aws ec2 describe-internet-gateways --filters Name=tag:Name,Values=clf-lab17-igw --query 'InternetGateways[0].InternetGatewayId' --output text)
RTB_ID=$(aws ec2 describe-route-tables --filters Name=tag:Name,Values=clf-lab17-public-rt --query 'RouteTables[0].RouteTableId' --output text)
ASSOC_ID=$(aws ec2 describe-route-tables --filters Name=tag:Name,Values=clf-lab17-public-rt --query 'RouteTables[0].Associations[0].RouteTableAssociationId' --output text)
SG_ID=$(aws ec2 describe-security-groups --filters Name=tag:Name,Values=clf-lab17-web-sg --query 'SecurityGroups[0].GroupId' --output text)
ACL_ID=$(aws ec2 describe-network-acls --filters Name=tag:Name,Values=clf-lab17-nacl --query 'NetworkAcls[0].NetworkAclId' --output text)
echo $VPC_ID $PUB_ID $PRIV_ID $IGW_ID $RTB_ID $ASSOC_ID $SG_ID $ACL_ID

It should print eight IDs. If one prints None, that resource is already gone: skip its delete below. 2. Delete the network ACL's two rules, then the network ACL itself:

aws ec2 delete-network-acl-entry --network-acl-id $ACL_ID --ingress --rule-number 100
aws ec2 delete-network-acl-entry --network-acl-id $ACL_ID --ingress --rule-number 200
aws ec2 delete-network-acl --network-acl-id $ACL_ID
  1. Delete the security group:
aws ec2 delete-security-group --group-id $SG_ID
  1. Remove the public route table's association and its route, then delete the route table:
aws ec2 disassociate-route-table --association-id $ASSOC_ID
aws ec2 delete-route --route-table-id $RTB_ID --destination-cidr-block 0.0.0.0/0
aws ec2 delete-route-table --route-table-id $RTB_ID
  1. Detach the internet gateway from the VPC and delete it:
aws ec2 detach-internet-gateway --internet-gateway-id $IGW_ID --vpc-id $VPC_ID
aws ec2 delete-internet-gateway --internet-gateway-id $IGW_ID
  1. Delete both subnets, then the VPC. The VPC's main route table, default security group and default network ACL go with it:
aws ec2 delete-subnet --subnet-id $PUB_ID
aws ec2 delete-subnet --subnet-id $PRIV_ID
aws ec2 delete-vpc --vpc-id $VPC_ID

If delete-vpc reports a dependency, an earlier step did not finish: run step 1 again to see which ID is still there.

Check it is gone

aws ec2 describe-vpcs --filters Name=tag:Name,Values=clf-lab17-vpc --query 'length(Vpcs)'

Expected output: 0.

aws ec2 describe-internet-gateways --filters Name=tag:Name,Values=clf-lab17-igw --query 'length(InternetGateways)'

Expected output: 0.

aws ec2 describe-subnets --filters Name=tag:Name,Values=clf-lab17-public,clf-lab17-private --query 'length(Subnets)'

Expected output: 0.

Notes

The security group in this lab admits HTTPS only from the VPC's own range. A real web server's group would admit HTTPS from the internet, but this lab has no server, so there is no reason to open anything. Never open SSH (port 22) or RDP (port 3389) to the whole internet; use AWS Systems Manager Session Manager or a narrow address range instead. Amazon Inspector, which the lesson describes, is a scanning service that starts charging once its free trial ends, so it is not switched on here. A NAT gateway is charged for every hour it exists, which is why the private subnet in this lab has no outbound route at all.

This is an independent study companion for the AWS Certified Cloud Practitioner (CLF-C02) exam and is not produced by or endorsed by Amazon Web Services. AWS, Amazon Web Services and AWS Certified Cloud Practitioner are trademarks of Amazon.com, Inc. or its affiliates.

This is an independent study companion for AWS Certified Cloud Practitioner CLF-C02 and is not produced by or endorsed by CompTIA.