Skip to content

AWS Infrastructure Tour

This activity puts into practice the concepts from the Cloud Networking, Storage, and Identity lecture. You will inspect the default VPC, build the routing and security scaffold for a two-tier design, attach an IAM role to a public EC2 probe host, validate access to a private-tier instance, upload files to S3 without touching a credential file, and push a container image to a private ECR registry. By the end, you will have the network and security pieces of a two-tier VPC in place, two instances playing the public and private roles, and a container image running on your public probe host with no credentials stored anywhere on disk.

  • Your AWS Academy Learner Lab session started and the AWS Console open in your browser
  • The .pem key file from your previous lab work, accessible on your local machine
  • A terminal with ssh available

Before building something better, spend a few minutes looking at what AWS gives every account by default. This makes the case for a custom VPC concrete.

  1. In the AWS Console, navigate to VPC > Your VPCs. You should see one VPC labeled “default.” Record its IPv4 CIDR block.

  2. In the left sidebar, click Subnets. Use the filter bar to filter by the default VPC’s ID.

    Record how many subnets exist, which availability zones they are in, and the CIDR block of each one.

  3. Click any one of the default subnets and open its Route table tab. Find the row where the destination is 0.0.0.0/0 and note what the target is.

    A target that starts with igw- is an internet gateway. This entry means: route all traffic with no more-specific match out to the internet.

  4. Navigate to Internet Gateways in the left sidebar. Confirm that the default VPC has an internet gateway already attached.

  5. Navigate to Security Groups and find the security group named “default.” Click it and open the Inbound rules tab.

    The default inbound rule allows all traffic from any source that shares the same security group. This means every instance attached to that default security group can reach every other instance attached to that same default security group on every port, regardless of what they are running.


You will create a VPC with one public subnet and one private subnet in the same availability zone. The difference between them is entirely in their route table: the public subnet routes to an internet gateway, the private one does not. For class, you will later use a public EC2 instance as a probe host because it is easy to SSH into. The lecture’s production pattern is still the one to remember: public entry point, private application tier, private data tier.

  1. Navigate to VPC > Your VPCs and click Create VPC.

    • Resources to create: VPC only
    • Name tag: cs312-vpc
    • IPv4 CIDR block: 10.0.0.0/16

    Leave all other settings at defaults and click Create VPC.

  2. Navigate to Subnets and click Create subnet.

    • VPC ID: cs312-vpc
    • Subnet name: public-subnet
    • Availability zone: us-east-1a
    • IPv4 CIDR block: 10.0.1.0/24

    Click Create subnet.

  3. Create a second subnet in the same VPC:

    • Subnet name: private-subnet
    • Availability zone: us-east-1a
    • IPv4 CIDR block: 10.0.2.0/24
  4. Navigate to Internet Gateways and click Create internet gateway.

    • Name tag: cs312-igw

    Click Create internet gateway. On the confirmation page, click Attach to a VPC and select cs312-vpc.

  5. Navigate to Route Tables and click Create route table.

    • Name: public-rt
    • VPC: cs312-vpc

    Click Create route table. With public-rt selected, open the Routes tab and click Edit routes. Click Add route: destination 0.0.0.0/0, target: your cs312-igw. Save the changes.

  6. Create a second route table for the private subnet:

    • Click Create route table, name it private-rt, VPC: cs312-vpc.
    • Do not add a 0.0.0.0/0 route. This table intentionally has no path to the internet.
  7. Associate each route table with its subnet.

    Select public-rt, open the Subnet associations tab, click Edit subnet associations, select public-subnet, and save. Repeat for private-rt and private-subnet.

  8. In Subnets, select public-subnet and choose Actions > Edit subnet settings. Enable Auto-assign public IPv4 address and save.

    Instances launched in public-subnet will now receive a public IP automatically. Instances in private-subnet will not.


Security groups can reference each other as sources rather than relying on IP addresses. This is the pattern used to enforce trust between tiers without hardcoding IPs that change when instances are replaced.

  1. Navigate to EC2 > Security Groups and click Create security group.

    • Name: web-sg
    • Description: Web tier: HTTP, HTTPS, SSH
    • VPC: cs312-vpc

    Add the following inbound rules:

    • Type: HTTP (TCP 80), source: 0.0.0.0/0
    • Type: HTTPS (TCP 443), source: 0.0.0.0/0
    • Type: SSH (TCP 22), source: My IP (the console fills in your current IP address)

    Leave outbound rules at the default (allow all) and click Create security group.

  2. Create a second security group:

    • Name: db-sg
    • Description: Database tier: PostgreSQL from web tier only
    • VPC: cs312-vpc

    Add one inbound rule:

    • Type: Custom TCP
    • Port range: 5432
    • Source type: Custom
    • Source: begin typing web-sg and select it from the dropdown (not an IP address range)

    Click Create security group.

  3. Open db-sg and look at the inbound rule you just created. The Source column shows the security group ID of web-sg, not an IP address or CIDR.

    In plain language, this rule means: any EC2 instance that has web-sg attached is permitted to connect to port 5432 on instances that have db-sg attached. Everything else is denied.


IAM Roles, Instance Profiles, and the Metadata Service

Section titled “IAM Roles, Instance Profiles, and the Metadata Service”

AWS Academy’s LabRole is already configured as an IAM role that EC2 instances can assume. You will attach it to a public instance that acts as your probe host, launch a second private instance behind db-sg, and then use the public host to validate both the credential flow and the tier rule you created earlier.

  1. First, inspect the role. Navigate to IAM > Roles and search for LabRole. Click it.

    Open the Permissions tab and note the attached policies. Then open the Trust relationships tab.

    In the JSON trust policy, find the "Service" key. Its value is ec2.amazonaws.com. This is the declaration that allows EC2 instances to assume this role. Notice how much access the role appears to have overall. In AWS Academy that breadth is intentional for a sandbox. In production, the least-privilege pattern from the lecture would normally split this into smaller roles with much narrower permissions.

  2. Navigate to EC2 > Instances and click Launch instances.

    • Name: cs312-web
    • AMI: Ubuntu Server 26.04 LTS (64-bit x86)
    • Instance type: t3.micro
    • Key pair: select your existing key pair
    • Network settings: click Edit
      • VPC: cs312-vpc
      • Subnet: public-subnet
      • Auto-assign public IP: Enable
      • Security group: select existing web-sg
    • Expand Advanced details and under IAM instance profile, select LabInstanceProfile

    Click Launch instance.

  3. Launch a second instance that plays the private-tier role.

    • Name: cs312-db-probe
    • AMI: Ubuntu Server 26.04 LTS (64-bit x86)
    • Instance type: t3.micro
    • Key pair: select your existing key pair
    • Network settings: click Edit
      • VPC: cs312-vpc
      • Subnet: private-subnet
      • Auto-assign public IP: Disable
      • Security group: select existing db-sg
    • Expand Advanced details and paste this into User data:
    #!/bin/bash
    cat >/usr/local/bin/listen-5432.py <<'PY'
    import socket
    server = socket.socket()
    server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    server.bind(("0.0.0.0", 5432))
    server.listen()
    while True:
    conn, addr = server.accept()
    conn.sendall(b"cs312-db-probe\n")
    conn.close()
    PY
    nohup python3 /usr/local/bin/listen-5432.py >/var/log/listen-5432.log 2>&1 &

    This boot-time script starts a tiny TCP listener on port 5432 so you can test the db-sg rule later without installing PostgreSQL.

  4. While the instances are starting, navigate to EC2 > Instances, select cs312-web, and open the Storage tab. Under Block devices, find the root volume entry (/dev/sda1).

    Note the Volume ID, Volume size (8 GiB), and Delete on termination (Yes). Click the Volume ID link to open it in EC2 > Volumes and note the Type (gp3) and State (in-use). This root volume was created automatically from the AMI’s snapshot when you launched the instance and will be removed when you terminate the instance because Delete on termination is enabled.

  5. Wait for both instances to show Running and for both status checks to pass. This takes 2-3 minutes. Record the Public IPv4 address of cs312-web and the Private IPv4 address of cs312-db-probe.

  6. On your local machine, load your key into the SSH agent so agent forwarding works later, then connect to cs312-web:

    Terminal window
    ssh-add /path/to/your-key.pem
    ssh -A -i /path/to/your-key.pem ubuntu@<WEB_PUBLIC_IP>

    The -A flag forwards your local SSH agent to the session. This lets you use the same key for onward connections without copying the .pem file to cs312-web.

  7. From inside cs312-web, request an IMDSv2 token:

    Terminal window
    TOKEN=$(curl -s -X PUT "http://169.254.169.254/latest/api/token" \
    -H "X-aws-ec2-metadata-token-ttl-seconds: 21600")
    echo "$TOKEN"

    The token is long and not human-friendly. Its presence is what matters. You will reuse it in the next metadata queries.

  8. Query the metadata service root to see what categories are available:

    Terminal window
    curl -H "X-aws-ec2-metadata-token: $TOKEN" \
    http://169.254.169.254/latest/meta-data/

    You will see a list: ami-id, hostname, iam/, instance-type, local-ipv4, and others. The address 169.254.169.254 is a link-local address reserved for this purpose; it is not routable beyond the instance. If this command returns an error because $TOKEN is empty, revisit step 7 and check the command carefully; IMDSv2 is required on current EC2 launches.

  9. Query the IAM path to find the name of the attached role:

    Terminal window
    ROLE_NAME=$(curl -s -H "X-aws-ec2-metadata-token: $TOKEN" \
    http://169.254.169.254/latest/meta-data/iam/security-credentials/)
    echo "$ROLE_NAME"

    This returns LabRole, the name of the role attached via the instance profile.

  10. Retrieve the actual credentials:

    Terminal window
    curl -H "X-aws-ec2-metadata-token: $TOKEN" \
    "http://169.254.169.254/latest/meta-data/iam/security-credentials/$ROLE_NAME"

    The JSON response contains AccessKeyId, SecretAccessKey, Token, and Expiration. Note the Expiration timestamp: these credentials rotate automatically a few hours before they expire.

  11. Install the AWS CLI v2 and a TCP probe tool, then confirm the CLI uses the role without any configuration:

    Terminal window
    sudo apt update -q && sudo apt install -y unzip netcat-openbsd
    curl -s "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o awscliv2.zip
    unzip -q awscliv2.zip
    sudo ./aws/install
    aws sts get-caller-identity

    Ubuntu 26.04 does not ship awscli in its apt repositories, so you install it from the official AWS bundle instead. The response shows your account ID and the role ARN. An ARN (Amazon Resource Name) is a globally unique identifier that AWS uses to refer to any resource across any account and region; you will see them throughout the AWS CLI and console. The CLI found and used the credentials from the metadata service without a single aws configure prompt.

  12. Validate the security-group relationship you built earlier by probing the private instance on port 5432:

    Terminal window
    nc -zv <DB_PRIVATE_IP> 5432

    Replace <DB_PRIVATE_IP> with the private IP you recorded for cs312-db-probe. You should see a message ending in succeeded. This proves two things at once: the VPC has a local route between the two subnets, and the db-sg rule that references web-sg is allowing the connection even though the target instance has no public IP.

  13. To SSH into the private instance, db-sg needs to permit it. In the AWS Console, open EC2 > Security Groups, select db-sg, and click Edit inbound rules. Add:

    • Type: SSH (TCP 22), Source: web-sg

    Save the rule.

  14. From inside cs312-web, SSH to the private instance using only its private IP:

    Terminal window
    ssh ubuntu@<DB_PRIVATE_IP>

    Your agent-forwarded key handles authentication. You should land at a shell prompt on cs312-db-probe. Run hostname to confirm which instance you are on. Notice that this instance has no public IP: the only way in is through cs312-web within the VPC. Type exit to return to cs312-web.


Your instance already has credentials from the metadata service. This section uses them to create an S3 bucket and upload a file, demonstrating that the instance role handles authentication transparently for any AWS service.

  1. From your EC2 SSH session, create an S3 bucket. Bucket names must be globally unique, so include your ONID:

    Terminal window
    ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
    BUCKET="cs312-YOUR_ONID-$ACCOUNT_ID"
    aws s3 mb "s3://$BUCKET" --region us-east-1

    Replace YOUR_ONID with your actual ONID, lowercase. Adding the account ID avoids bucket-name collisions.

  2. Write a small file and upload it:

    Terminal window
    echo "uploaded from cs312-web via instance role" > testfile.txt
    aws s3 cp testfile.txt "s3://$BUCKET/testfile.txt"
  3. List the bucket contents to confirm the upload:

    Terminal window
    aws s3 ls "s3://$BUCKET/"

    You should see testfile.txt with its size and timestamp.

  4. Download the file back under a different name to confirm retrieval:

    Terminal window
    aws s3 cp "s3://$BUCKET/testfile.txt" retrieved.txt
    cat retrieved.txt
  5. In the AWS Console, navigate to S3 and find your bucket. Open its Permissions tab.

    Look at the Block public access section. All four settings are enabled by default. No object in this bucket can be made public unless you explicitly disable these controls. Then look at the Bucket policy section. It should be empty unless you add one. That means the access you just used came from the instance role’s identity-based permissions, not from a resource-based policy on the bucket itself.

  6. Open the Management tab. This is where lifecycle rules are configured. Click Create lifecycle rule.

    • Rule name: archive-old-files
    • Scope: Apply to all objects in the bucket
    • Add a transition action: move to S3 Glacier Flexible Retrieval after 30 days

    Do not save the rule: click Cancel after noting the available options.

  7. In the AWS Console, open your bucket’s Permissions tab and click Edit under Bucket policy. Paste the policy below, replacing YOUR_BUCKET_NAME with the value of $BUCKET:

    {
    "Version": "2012-10-17",
    "Statement": [
    {
    "Sid": "DenyUnencryptedUploads",
    "Effect": "Deny",
    "Principal": "*",
    "Action": "s3:PutObject",
    "Resource": "arn:aws:s3:::YOUR_BUCKET_NAME/*",
    "Condition": {
    "StringNotEquals": {
    "s3:x-amz-server-side-encryption": "AES256"
    }
    }
    }
    ]
    }

    Save the policy.

  8. Back in your SSH session, retry the upload without any encryption flag:

    Terminal window
    aws s3 cp testfile.txt "s3://$BUCKET/denied-upload.txt"

    You should see: An error occurred (AccessDenied) when calling the PutObject operation. The explicit Deny in the bucket policy overrode the Allow from the IAM role. An explicit deny always wins in IAM policy evaluation, regardless of what any allow says.

  9. Retry the upload with server-side encryption specified:

    Terminal window
    aws s3 cp testfile.txt "s3://$BUCKET/encrypted-upload.txt" \
    --sse AES256

    This upload succeeds. The condition in the Deny statement is no longer met, so the deny does not apply and the role’s allow takes effect.

  10. In the AWS Console, open your bucket’s Objects tab. You should see encrypted-upload.txt listed but no denied-upload.txt. The denied upload never completed, so it left no object behind. The presence of one and the absence of the other is the confirmation that the policy worked.


You will create a private container registry, install Docker on the EC2 instance, build an image that contains your ONID, and push it to ECR. The entire authentication chain uses the instance role you already attached.

  1. In the AWS Console, navigate to ECR > Repositories and click Create repository.

    • Repository name: cs312-YOUR_ONID (your actual ONID, lowercase)

    Leave all other settings at defaults. ECR repositories are private by default. Click Create repository. Copy the full URI shown for the repository (it looks like <account-id>.dkr.ecr.us-east-1.amazonaws.com/cs312-YOUR_ONID).

  2. Back in your EC2 SSH session, install Docker:

    Terminal window
    sudo apt install -y docker.io
    sudo usermod -aG docker ubuntu
    newgrp docker
  3. Verify Docker is running:

    Terminal window
    docker version

    If this returns a permission error, disconnect from SSH and reconnect. The group change from step 2 will take effect on the new session.

  4. Retrieve your AWS account ID, which you need to construct the ECR login command:

    Terminal window
    aws sts get-caller-identity --query Account --output text

    Record the number returned.

  5. Authenticate Docker to ECR. The AWS CLI uses the instance role to obtain a temporary registry token:

    Terminal window
    aws ecr get-login-password --region us-east-1 | \
    docker login --username AWS --password-stdin \
    <ACCOUNT_ID>.dkr.ecr.us-east-1.amazonaws.com

    Replace <ACCOUNT_ID> with the value from step 4. You should see Login Succeeded.

  6. Create a small image that carries your ONID:

    Terminal window
    mkdir myapp && cd myapp
    echo '<h1>CS 312 - YOUR_ONID</h1>' > index.html
    cat > Dockerfile << 'EOF'
    FROM nginx:alpine
    COPY index.html /usr/share/nginx/html/index.html
    EOF

    Build it:

    Terminal window
    docker build -t cs312-YOUR_ONID:v1 .
  7. Tag the image with both a version tag and latest, then push both:

    Terminal window
    docker tag cs312-YOUR_ONID:v1 \
    <ACCOUNT_ID>.dkr.ecr.us-east-1.amazonaws.com/cs312-YOUR_ONID:v1
    docker tag cs312-YOUR_ONID:v1 \
    <ACCOUNT_ID>.dkr.ecr.us-east-1.amazonaws.com/cs312-YOUR_ONID:latest
    docker push <ACCOUNT_ID>.dkr.ecr.us-east-1.amazonaws.com/cs312-YOUR_ONID:v1
    docker push <ACCOUNT_ID>.dkr.ecr.us-east-1.amazonaws.com/cs312-YOUR_ONID:latest

    Both tags point to the same image digest. latest is a convention for “the current version”; v1 is a permanent, immutable reference you can roll back to.

  8. In the AWS Console, refresh your ECR repository page. You should see both v1 and latest listed, sharing the same digest. Click either tag and open the Scan results tab.

    If the registry has basic scanning enabled, findings will appear here within a minute or two of the push. Note any findings and their severity levels. If the tab shows no results, retrieve them from the CLI instead:

    Terminal window
    aws ecr describe-image-scan-findings \
    --repository-name cs312-YOUR_ONID \
    --image-id imageTag=v1 \
    --region us-east-1

    Look for the findingSeverityCounts field in the output. A nginx:alpine image typically shows informational or low findings from package metadata; note whether any appear as CRITICAL or HIGH.

  9. Pull the image back from ECR to confirm the full round-trip:

    Terminal window
    docker pull \
    <ACCOUNT_ID>.dkr.ecr.us-east-1.amazonaws.com/cs312-YOUR_ONID:v1
  10. Run the image and verify it serves your content:

    Terminal window
    docker run -d -p 9090:80 --name myapp \
    <ACCOUNT_ID>.dkr.ecr.us-east-1.amazonaws.com/cs312-YOUR_ONID:v1
    curl http://localhost:9090

    The response should contain your ONID.


You now have the core scaffold in place. The next useful step is to push the activity a little closer to the lecture’s production patterns and operational controls.

  • Add a NAT gateway to cs312-vpc so instances in private-subnet can make outbound requests (software updates, package downloads) without being directly reachable from the internet. Follow the NAT gateway documentation for the exact steps. Notice the tradeoff from the lecture: NAT gateways are the standard managed path, but they also incur hourly and data-processing charges.
  • Save the lifecycle rule you configured in the S3 section and observe how it appears in the Management tab. Add a second action that permanently deletes objects after 365 days so the bucket reflects the storage-lifecycle pattern from the lecture.