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.
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.netaddress. - 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)
TipRun
aws sts get-caller-identitybefore starting. If it prints your account ID, the CLI is configured correctly.
SecurityCosts 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.
mkdir cloud-resume && cd cloud-resumegit init -b mainmkdir frontend backend .github/workflows<!-- 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>/* 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; }// frontend/index.jsconst 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 MistakeExpecting the counter to work when you open the file straight from your disk after the API exists. Browsers send
nullas the origin for local files, and the API will only allow your CloudFront address. Test the counter through the CloudFront URL, not throughfile://.
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.
export REGION=eu-west-1export 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
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
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.jsonaws 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 MistakeGetting
403 AccessDeniedfrom 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.
SecurityThe 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.
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.
aws dynamodb describe-table --table-name cloud-resume-visitors --region "$REGION" \ --query 'Table.[TableStatus,TableArn]' --output textExpected result: ACTIVE and the table ARN. Save the ARN in a variable for the next milestone:
export TABLE_ARN=$(aws dynamodb describe-table --table-name cloud-resume-visitors \ --region "$REGION" --query Table.TableArn --output text)TipA read-then-write counter (read the value, add one in code, write it back) loses updates under load. An atomic
ADDis 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
# backend/lambda_function.pyimport jsonimport 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.
# backend/test_lambda.pyimport jsonimport unittestfrom 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()cd backendpip install boto3python -m unittest -v test_lambdacd ..Expected result: one test, OK.
Create a least-privilege role
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.jsonsleep 10The 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
cd backendzip 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.jsoncat out.jsonExpected result: a status of 200 and a body such as {"count": 1}. Run the invoke again and the number goes up.
Common MistakeGiving 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.
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 functionaws 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 billaws 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
curl -s -i -H "Origin: https://$CF_DOMAIN" "$API_URL" | grep -i access-controlcurl -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 MistakeForgetting
lambda add-permission. The route exists but API Gateway is not allowed to call the function, and every request returns500 Internal Server Error. The permission is a separate step from creating the API.
TipThrottling 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.
# Linux. On macOS use: sed -i '' "s|...|...|" frontend/index.jssed -i "s|YOUR_API_URL_HERE|$API_URL|" frontend/index.jsgrep API_URL frontend/index.js | head -1 aws s3 sync frontend/ "s3://$BUCKET/" --deleteaws 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 MistakeUploading 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
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.jsonThe 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
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.jsonStore 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
# .github/workflows/deploy.ymlname: 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 "/*"git add frontend backend .githubgit commit -m "Cloud resume: site, API and pipeline"git remote add origin git@github.com:YOUR_USER/cloud-resume.gitgit push -u origin mainDo not commit the generated *.json policy files or function.zip; add them to .gitignore.
Common MistakeA workflow failing with "Not authorized to perform sts:AssumeRoleWithWebIdentity". The
subvalue in the trust policy must match your repository name and branch exactly, including capitalisation.
SecurityNever 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.
# 1. The site loads over HTTPS through CloudFrontcurl -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 callcurl -s "$API_URL"; curl -s "$API_URL"# Expected: two responses, the second one number higher # 4. DynamoDB holds the valueaws dynamodb get-item --table-name cloud-resume-visitors \ --key '{"id":{"S":"visitors"}}' --region "$REGION" # 5. The pipeline redeploys on a changesed -i 's/Cloud Engineer/Cloud and DevOps Engineer/' frontend/index.htmlgit commit -am "Update headline" && git push# Expected: all Actions jobs pass and the new text appears at your CloudFront URLChecklist
- 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
UpdateItemon one table - The deploy role can only be assumed by your repository's
mainbranch - A push to
mainruns 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
ADDprotect against that read-then-write does not? - What does the OIDC
subcondition 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.
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 itaws 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-countaws iam detach-role-policy --role-name cloud-resume-lambda-role --policy-arn arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRoleaws iam delete-role --role-name cloud-resume-lambda-rolePortfolio 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.
TipInterviewers 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.