Skip to main content

Cloud Resume Challenge on AWS

Deploy your resume as a serverless AWS site with a live page-view counter using S3, CloudFront, Lambda, DynamoDB and GitHub Actions.

Blueprint Walkthrough

Architecture Overview & Problem Statement

This project deploys your resume as a serverless AWS application with a live visitor counter. CloudFront serves a static site from a private S3 bucket over HTTPS, and a small piece of JavaScript calls an API Gateway endpoint that runs a Lambda function, which adds one to a counter in DynamoDB. GitHub Actions then tests and deploys both the site and the function on every push, using short-lived credentials instead of stored keys.

A PDF resume proves nothing about your cloud skills. This project gives you a real URL and a working system with a CDN, a private bucket, an API, a database, least-privilege permissions and a pipeline, which is exactly what an interviewer can ask you to explain.

flowchart TD B[Browser] -->|HTTPS| CF[CloudFront] CF -->|private access| S3[(S3 bucket)] B -->|fetch GET /count| API[API Gateway
HTTP API] API --> L[Lambda function] L -->|atomic increment| DB[(DynamoDB table)] GH[GitHub Actions] -.deploys.-> S3 GH -.deploys.-> L classDef user fill:#475569,color:#fff classDef cdn fill:#F97316,color:#fff classDef store fill:#10B981,color:#fff classDef api fill:#326CE5,color:#fff classDef fn fill:#8B5CF6,color:#fff class B,GH user class CF cdn class S3,DB store class API api class L fn

What each service does

  • S3 stores your files. Here the bucket stays private; only CloudFront may read it.
  • CloudFront is a content delivery network. It caches your files near visitors and gives you HTTPS on a cloudfront.net address.
  • DynamoDB is a key-value database. You only define the primary key up front, and each item holds whatever attributes you give it.
  • Lambda runs a small function only when called, and you pay per millisecond of use.
  • API Gateway turns the function into an HTTPS URL the browser can call, and lets you limit which websites may call it.

What you need first

  • An AWS account and the AWS CLI v2, configured with aws configure
  • A GitHub account and a terminal on Linux, macOS or WSL
  • Python 3.13 locally for the unit test (any recent 3.x works)
Tip

Run aws sts get-caller-identity before starting. If it prints your account ID, the CLI is configured correctly.

Security

Costs for this project are small, but they are not zero and depend on your account. Set a billing alert before you start, and run the cleanup step when you finish if you do not want to keep the site.

Milestone 1: Write the Frontend

This milestone creates a plain HTML page with no framework or build step, so all the attention stays on AWS. The page has one dynamic part: a span that JavaScript fills in with the visitor count.

Bash
mkdir cloud-resume && cd cloud-resume
git init -b main
mkdir frontend backend .github/workflows
HTML
<!-- frontend/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Cloud Resume</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<main class="container">
<h1>Your Name</h1>
<p>Cloud Engineer | AWS | Terraform</p>
<footer>Page views: <span id="visitor-count">Loading...</span></footer>
</main>
<script src="index.js"></script>
</body>
</html>
CSS
/* frontend/style.css */
body { font-family: Arial, sans-serif; max-width: 700px; margin: 60px auto; color: #222; }
.container { padding: 20px; }
footer { margin-top: 40px; color: #666; font-size: 0.9em; }
JAVASCRIPT
// frontend/index.js
const API_URL = 'YOUR_API_URL_HERE'; // replaced in Milestone 6
async function updateVisitorCount() {
const label = document.getElementById('visitor-count');
try {
const res = await fetch(API_URL);
if (!res.ok) throw new Error('HTTP ' + res.status);
const data = await res.json();
label.textContent = data.count;
} catch (err) {
label.textContent = 'unavailable';
}
}
updateVisitorCount();

Open frontend/index.html in a browser. The layout should look right and the counter shows unavailable, which is expected because there is no API yet.

Common Mistake

Expecting the counter to work when you open the file straight from your disk after the API exists. Browsers send null as the origin for local files, and the API will only allow your CloudFront address. Test the counter through the CloudFront URL, not through file://.

Milestone 2: Host the Site on a Private S3 Bucket Behind CloudFront

This milestone publishes the site the modern way: the bucket stays completely private and CloudFront is the only thing allowed to read it, using origin access control (OAC). Making the bucket public, which older tutorials do, exposes files directly and bypasses HTTPS and caching.

Create the bucket

Bucket names are global across all of AWS, so include your account ID to make yours unique.

Bash
export REGION=eu-west-1
export ACCOUNT_ID=$(aws sts get-caller-identity --query Account --output text)
export BUCKET=cloud-resume-$ACCOUNT_ID
aws s3api create-bucket --bucket "$BUCKET" --region "$REGION" \
--create-bucket-configuration LocationConstraint="$REGION"
aws s3 sync frontend/ "s3://$BUCKET/"
aws s3api get-public-access-block --bucket "$BUCKET"

If you chose us-east-1, remove the --create-bucket-configuration line, because that region does not accept it. Expected result: all four public access settings show true. Leave them on.

Create the access control and the distribution

Bash
OAC_ID=$(aws cloudfront create-origin-access-control \
--origin-access-control-config "Name=resume-oac,SigningProtocol=sigv4,SigningBehavior=always,OriginAccessControlOriginType=s3" \
--query OriginAccessControl.Id --output text)
cat > dist-config.json << EOF
{
"CallerReference": "resume-$(date +%s)",
"Comment": "cloud resume",
"Enabled": true,
"DefaultRootObject": "index.html",
"Origins": {
"Quantity": 1,
"Items": [{
"Id": "s3-resume",
"DomainName": "$BUCKET.s3.$REGION.amazonaws.com",
"OriginAccessControlId": "$OAC_ID",
"S3OriginConfig": { "OriginAccessIdentity": "" }
}]
},
"DefaultCacheBehavior": {
"TargetOriginId": "s3-resume",
"ViewerProtocolPolicy": "redirect-to-https",
"CachePolicyId": "658327ea-f89d-4fab-a63d-7e88639e58f6",
"AllowedMethods": { "Quantity": 2, "Items": ["GET", "HEAD"] },
"Compress": true
}
}
EOF
read CF_ID CF_DOMAIN CF_ARN < <(aws cloudfront create-distribution \
--distribution-config file://dist-config.json \
--query 'Distribution.[Id,DomainName,ARN]' --output text)
echo "$CF_ID $CF_DOMAIN"

The cache policy ID above is AWS's managed CachingOptimized policy, so you do not need to write cache rules yourself.

Allow only this distribution to read the bucket

Bash
cat > bucket-policy.json << EOF
{
"Version": "2012-10-17",
"Statement": [{
"Sid": "AllowCloudFrontRead",
"Effect": "Allow",
"Principal": { "Service": "cloudfront.amazonaws.com" },
"Action": "s3:GetObject",
"Resource": "arn:aws:s3:::$BUCKET/*",
"Condition": { "StringEquals": { "AWS:SourceArn": "$CF_ARN" } }
}]
}
EOF
aws s3api put-bucket-policy --bucket "$BUCKET" --policy file://bucket-policy.json
aws cloudfront wait distribution-deployed --id "$CF_ID"
curl -I "https://$CF_DOMAIN"

Expected result: after the distribution finishes deploying (often 5 to 15 minutes), curl returns HTTP/2 200, and the page loads in your browser with a padlock. A direct request to the S3 address returns AccessDenied, which is the point.

Common Mistake

Getting 403 AccessDenied from CloudFront. The usual causes are applying the bucket policy before the distribution exists, a typo in the distribution ARN in the policy, or a file that was never uploaded. Check each in that order.

Security

The bucket policy grants read access to one named CloudFront distribution, and to nobody else. Never replace it with "Principal": "*" to "fix" an error.

Milestone 3: Create the DynamoDB Table

This milestone creates the table that holds the counter. DynamoDB needs only a primary key defined in advance. Here the key is a string called id, and the table will hold a single item.

Bash
aws dynamodb create-table \
--table-name cloud-resume-visitors \
--attribute-definitions AttributeName=id,AttributeType=S \
--key-schema AttributeName=id,KeyType=HASH \
--billing-mode PAY_PER_REQUEST \
--region "$REGION"
aws dynamodb wait table-exists --table-name cloud-resume-visitors --region "$REGION"

PAY_PER_REQUEST means you pay per read and write instead of reserving capacity, which suits a site with little traffic.

You do not need to insert a starting item. The Lambda function uses an ADD update, which creates the item with a count of 1 the first time it runs and increments it afterwards. The increment happens inside DynamoDB in one step, so two visitors at the same moment can never overwrite each other's update.

Bash
aws dynamodb describe-table --table-name cloud-resume-visitors --region "$REGION" \
--query 'Table.[TableStatus,TableArn]' --output text

Expected result: ACTIVE and the table ARN. Save the ARN in a variable for the next milestone:

Bash
export TABLE_ARN=$(aws dynamodb describe-table --table-name cloud-resume-visitors \
--region "$REGION" --query Table.TableArn --output text)
Tip

A read-then-write counter (read the value, add one in code, write it back) loses updates under load. An atomic ADD is the correct pattern for any counter.

Milestone 4: Write, Test and Deploy the Lambda Function

This milestone writes the function, tests it without touching AWS, then deploys it with an IAM role that can do exactly one thing: update this one table.

Write the function

Python
# backend/lambda_function.py
import json
import os
import boto3
TABLE_NAME = os.environ.get("TABLE_NAME", "cloud-resume-visitors")
def lambda_handler(event, context):
table = boto3.resource("dynamodb").Table(TABLE_NAME)
response = table.update_item(
Key={"id": "visitors"},
UpdateExpression="ADD #c :inc",
ExpressionAttributeNames={"#c": "count"},
ExpressionAttributeValues={":inc": 1},
ReturnValues="UPDATED_NEW",
)
count = int(response["Attributes"]["count"])
return {
"statusCode": 200,
"headers": {"Content-Type": "application/json"},
"body": json.dumps({"count": count}),
}

The function does not set CORS headers. You configure CORS on the API in the next milestone, so the rule lives in one place.

Write a unit test

The test replaces the database call with a fake, so it runs instantly and costs nothing.

Python
# backend/test_lambda.py
import json
import unittest
from unittest.mock import MagicMock, patch
import lambda_function
class CounterTest(unittest.TestCase):
@patch("lambda_function.boto3.resource")
def test_returns_incremented_count(self, resource):
table = MagicMock()
table.update_item.return_value = {"Attributes": {"count": 42}}
resource.return_value.Table.return_value = table
result = lambda_function.lambda_handler({}, None)
self.assertEqual(result["statusCode"], 200)
self.assertEqual(json.loads(result["body"]), {"count": 42})
table.update_item.assert_called_once()
if __name__ == "__main__":
unittest.main()
Bash
cd backend
pip install boto3
python -m unittest -v test_lambda
cd ..

Expected result: one test, OK.

Create a least-privilege role

Bash
cat > trust-policy.json << 'EOF'
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "Service": "lambda.amazonaws.com" },
"Action": "sts:AssumeRole"
}]
}
EOF
aws iam create-role --role-name cloud-resume-lambda-role \
--assume-role-policy-document file://trust-policy.json
aws iam attach-role-policy --role-name cloud-resume-lambda-role \
--policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
cat > table-policy.json << EOF
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Action": "dynamodb:UpdateItem",
"Resource": "$TABLE_ARN"
}]
}
EOF
aws iam put-role-policy --role-name cloud-resume-lambda-role \
--policy-name update-visitor-count --policy-document file://table-policy.json
sleep 10

The first managed policy only lets the function write its own logs. The second allows UpdateItem on one table and nothing else. The pause gives IAM time to spread the new role.

Deploy the function

Bash
cd backend
zip function.zip lambda_function.py
aws lambda create-function \
--function-name cloud-resume-counter \
--runtime python3.13 \
--role "arn:aws:iam::$ACCOUNT_ID:role/cloud-resume-lambda-role" \
--handler lambda_function.lambda_handler \
--zip-file fileb://function.zip \
--environment "Variables={TABLE_NAME=cloud-resume-visitors}" \
--region "$REGION"
cd ..
aws lambda invoke --function-name cloud-resume-counter --region "$REGION" out.json
cat out.json

Expected result: a status of 200 and a body such as {"count": 1}. Run the invoke again and the number goes up.

Common Mistake

Giving the function AmazonDynamoDBFullAccess "to get it working". A bug or injection in the function could then read or delete every table in the account. Scope the permission to the one action and one table the function needs.

Milestone 5: Expose the Function with API Gateway

A browser cannot call Lambda directly. API Gateway gives the function an HTTPS address. This project uses an HTTP API, the simpler and cheaper kind, and sets CORS so that only your CloudFront site may call it from a browser.

CORS (cross-origin resource sharing) is the browser rule that blocks a page on one website from reading responses from another unless that other site says it is allowed. Your page is on cloudfront.net and the API is on execute-api, so they are different origins and the API must opt in.

Bash
FUNCTION_ARN=$(aws lambda get-function --function-name cloud-resume-counter \
--region "$REGION" --query Configuration.FunctionArn --output text)
API_ID=$(aws apigatewayv2 create-api \
--name cloud-resume-api \
--protocol-type HTTP \
--route-key "GET /count" \
--target "$FUNCTION_ARN" \
--cors-configuration "{\"AllowOrigins\":[\"https://$CF_DOMAIN\"],\"AllowMethods\":[\"GET\"]}" \
--region "$REGION" --query ApiId --output text)
# Let API Gateway invoke the function
aws lambda add-permission \
--function-name cloud-resume-counter \
--statement-id apigw-invoke \
--action lambda:InvokeFunction \
--principal apigateway.amazonaws.com \
--source-arn "arn:aws:execute-api:$REGION:$ACCOUNT_ID:$API_ID/*/*/count" \
--region "$REGION"
# Limit request rates so nobody can run up your bill
aws apigatewayv2 update-stage --api-id "$API_ID" --stage-name '$default' \
--default-route-settings ThrottlingBurstLimit=10,ThrottlingRateLimit=5 \
--region "$REGION"
export API_URL="https://$API_ID.execute-api.$REGION.amazonaws.com/count"
curl -s "$API_URL"

Expected result: a JSON body such as {"count": 3}. Quick create adds a $default stage that deploys automatically, so there is no separate deployment step.

Check the CORS setting

Bash
curl -s -i -H "Origin: https://$CF_DOMAIN" "$API_URL" | grep -i access-control
curl -s -i -H "Origin: https://example.com" "$API_URL" | grep -i access-control || echo "no CORS header for other sites"

Expected result: the first request returns an access-control-allow-origin header for your site and the second returns none.

Common Mistake

Forgetting lambda add-permission. The route exists but API Gateway is not allowed to call the function, and every request returns 500 Internal Server Error. The permission is a separate step from creating the API.

Tip

Throttling protects you from a runaway loop or an abusive visitor, because every call invokes the function and writes to the database.

Milestone 6: Connect the Frontend and Publish

Now that the API URL exists, put it into the page and upload the new version. Because CloudFront caches files, you also invalidate the cache so visitors see the change straight away.

Bash
# Linux. On macOS use: sed -i '' "s|...|...|" frontend/index.js
sed -i "s|YOUR_API_URL_HERE|$API_URL|" frontend/index.js
grep API_URL frontend/index.js | head -1
aws s3 sync frontend/ "s3://$BUCKET/" --delete
aws cloudfront create-invalidation --distribution-id "$CF_ID" --paths "/*"

Open https://$CF_DOMAIN in your browser. The number should appear and increase each time you reload.

Note what the counter really measures: each page load calls the function once, so it counts page views, not unique people. A real visitor counter would need cookies or some other way to tell visitors apart, which is a good stretch goal.

Debug by layer

If the page still says unavailable, work from the inside out:

  • Run curl "$API_URL". If that fails, the problem is the API or Lambda, not the page.
  • Open the browser developer tools and look at the Console and Network tabs. A CORS error names the origin that was blocked.
  • Read the function's logs with aws logs tail /aws/lambda/cloud-resume-counter --region "$REGION".
Common Mistake

Uploading the new file and seeing the old one. CloudFront is serving its cached copy. Create an invalidation as above, or wait for the cache to expire.

Milestone 7: Automate with GitHub Actions and OIDC

This milestone makes every push to main test and deploy the backend and the frontend. The pipeline signs in to AWS with OIDC: GitHub proves to AWS who is running the workflow, and AWS hands out temporary credentials. There are no access keys stored in GitHub to leak.

Trust GitHub in your AWS account

Bash
GITHUB_REPO=YOUR_USER/cloud-resume
aws iam create-open-id-connect-provider \
--url https://token.actions.githubusercontent.com \
--client-id-list sts.amazonaws.com
cat > gh-trust.json << EOF
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": { "Federated": "arn:aws:iam::$ACCOUNT_ID:oidc-provider/token.actions.githubusercontent.com" },
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": "repo:$GITHUB_REPO:ref:refs/heads/main"
}
}
}]
}
EOF
aws iam create-role --role-name cloud-resume-deploy \
--assume-role-policy-document file://gh-trust.json

The sub condition is the important line: only workflows from your repository's main branch can assume this role.

Give the role only what deployment needs

Bash
cat > gh-permissions.json << EOF
{
"Version": "2012-10-17",
"Statement": [
{ "Effect": "Allow", "Action": ["s3:PutObject", "s3:DeleteObject"], "Resource": "arn:aws:s3:::$BUCKET/*" },
{ "Effect": "Allow", "Action": "s3:ListBucket", "Resource": "arn:aws:s3:::$BUCKET" },
{ "Effect": "Allow", "Action": "cloudfront:CreateInvalidation", "Resource": "$CF_ARN" },
{ "Effect": "Allow", "Action": ["lambda:UpdateFunctionCode", "lambda:GetFunction"], "Resource": "$FUNCTION_ARN" }
]
}
EOF
aws iam put-role-policy --role-name cloud-resume-deploy \
--policy-name deploy --policy-document file://gh-permissions.json

Store non-secret settings as repository variables

In your GitHub repository, open Settings, then Secrets and variables, then Actions, then Variables, and add AWS_ROLE_ARN (the ARN of cloud-resume-deploy), AWS_REGION, BUCKET and CF_ID. None of these is a secret, which is the benefit of OIDC.

The workflow

YAML
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
permissions:
id-token: write
contents: read
jobs:
test:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.13"
- name: Run unit tests
run: |
pip install boto3
cd backend && python -m unittest -v test_lambda
deploy-backend:
needs: test
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ vars.AWS_ROLE_ARN }}
aws-region: ${{ vars.AWS_REGION }}
- name: Deploy the function
run: |
cd backend
zip function.zip lambda_function.py
aws lambda update-function-code --function-name cloud-resume-counter --zip-file fileb://function.zip
aws lambda wait function-updated --function-name cloud-resume-counter
deploy-frontend:
needs: test
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ vars.AWS_ROLE_ARN }}
aws-region: ${{ vars.AWS_REGION }}
- name: Upload the site and clear the cache
run: |
aws s3 sync frontend/ "s3://${{ vars.BUCKET }}/" --delete
aws cloudfront create-invalidation --distribution-id "${{ vars.CF_ID }}" --paths "/*"
Bash
git add frontend backend .github
git commit -m "Cloud resume: site, API and pipeline"
git remote add origin git@github.com:YOUR_USER/cloud-resume.git
git push -u origin main

Do not commit the generated *.json policy files or function.zip; add them to .gitignore.

Common Mistake

A workflow failing with "Not authorized to perform sts:AssumeRoleWithWebIdentity". The sub value in the trust policy must match your repository name and branch exactly, including capitalisation.

Security

Never put AWS access keys in a repository or in GitHub secrets for a project like this. Bots scan public repositories for keys within minutes. OIDC removes the key entirely.

Validation & Testing

Check each layer on its own, then prove the pipeline.

Bash
# 1. The site loads over HTTPS through CloudFront
curl -sI "https://$CF_DOMAIN" | head -1
# Expected: HTTP/2 200
# 2. The bucket is private (direct access is denied)
curl -sI "https://$BUCKET.s3.$REGION.amazonaws.com/index.html" | head -1
# Expected: HTTP/1.1 403 Forbidden
# 3. The API counts up on each call
curl -s "$API_URL"; curl -s "$API_URL"
# Expected: two responses, the second one number higher
# 4. DynamoDB holds the value
aws dynamodb get-item --table-name cloud-resume-visitors \
--key '{"id":{"S":"visitors"}}' --region "$REGION"
# 5. The pipeline redeploys on a change
sed -i 's/Cloud Engineer/Cloud and DevOps Engineer/' frontend/index.html
git commit -am "Update headline" && git push
# Expected: all Actions jobs pass and the new text appears at your CloudFront URL

Checklist

  • The site is reachable over HTTPS and the bucket is not public
  • The API returns a rising count and rejects browsers from other origins
  • The Lambda role can only call UpdateItem on one table
  • The deploy role can only be assumed by your repository's main branch
  • A push to main runs the test, then deploys the function and the site

Reflection questions

  • Why use origin access control instead of a public bucket?
  • What does an atomic ADD protect against that read-then-write does not?
  • What does the OIDC sub condition stop?
  • Why does the counter count page views, not unique visitors?

Clean up

Delete everything if you do not want to keep it running. CloudFront must be disabled before it can be deleted, which takes several minutes.

Bash
aws cloudfront get-distribution-config --id "$CF_ID" --query ETag --output text
# Disable the distribution in the console or with update-distribution, wait, then delete it
aws s3 rm "s3://$BUCKET" --recursive && aws s3api delete-bucket --bucket "$BUCKET"
aws apigatewayv2 delete-api --api-id "$API_ID" --region "$REGION"
aws lambda delete-function --function-name cloud-resume-counter --region "$REGION"
aws dynamodb delete-table --table-name cloud-resume-visitors --region "$REGION"
aws iam delete-role-policy --role-name cloud-resume-lambda-role --policy-name update-visitor-count
aws iam detach-role-policy --role-name cloud-resume-lambda-role --policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
aws iam delete-role --role-name cloud-resume-lambda-role

Portfolio deliverable

Your repository README should contain the architecture diagram, a short explanation of each decision (private bucket, least privilege, OIDC, throttling), a link to the live site, and a screenshot of a passing pipeline run. Add a custom domain with Route 53 and a certificate as a stretch goal, then rebuild the whole stack with Terraform.

Tip

Interviewers care less that it works than that you can say why each piece is built the way it is. Practise explaining the security choices out loud.

Found a mistake, an outdated step or a broken link?