How I Built My AWS Cloud Resume Challenge 🚀
The Cloud Resume Challenge by Forrest Brazeal is one of the best hands-on projects for cloud engineers. Instead of just reading AWS docs, you build something real — a resume website with a serverless backend, all deployed through CI/CD pipelines. Here's exactly how I built mine.
Live site: tejtandel.com
Frontend repo: aws-cloud-resume-challenge
Infra repo: aws-cloud-resume-challenge-infra
Architecture Overview
Here's the full picture of what I built:
Browser → CloudFront (CDN + HTTPS)
↓
S3 Bucket (Static Website Hosting)
↓
JavaScript fetch()
↓
API Gateway (HTTP API)
↓
Lambda Function (Python 3.12)
↓
DynamoDB (Visitor Counter)
I split the project into two repositories following IaC best practices:
- Frontend repo — HTML/CSS/JS resume files + GitHub Actions to auto-deploy to S3
- Infrastructure repo — Terraform code for all backend AWS resources + GitHub Actions to auto-apply
Part 1: The Frontend
The frontend is a clean HTML/CSS/JavaScript resume page — no frameworks, no build step. I kept it intentionally simple so the cloud infrastructure is the star of the show.
File Structure
frontend/
├── index.html
├── style.css
├── script.js
├── Profile_Picture.jpg
└── Resume.pdf
The Visitor Counter (JavaScript)
The coolest part of the frontend is the live visitor counter. On every page load, script.js makes an async call to the backend API:
async function updateVisitorCount() {
const el = document.getElementById('counter');
if (!el) return;
try {
const res = await fetch('https://api-gateway/visitorcounter', {
method: 'GET',
headers: { 'Content-Type': 'application/json' }
});
if (res.ok) {
const data = await res.json();
el.textContent = (data && data.visitorcounter) ? data.visitorcounter.toLocaleString() : 'N/A';
}
} catch (err) {
console.error('Visitor counter error:', err);
el.textContent = 'N/A';
}
}
document.addEventListener('DOMContentLoaded', updateVisitorCount);
This calls the API Gateway endpoint, which triggers Lambda, which increments and returns the DynamoDB counter.
Part 2: Frontend CI/CD with GitHub Actions
Every time I push a change to the frontend/ folder, GitHub Actions automatically:
- Syncs all files to the S3 bucket
- Invalidates the CloudFront cache so visitors get fresh content instantly
.github/workflows/deploy-frontend.yml:
name: Deploy Frontend to S3
on:
push:
branches: [ main ]
paths: [ 'frontend/**' ]
workflow_dispatch:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ secrets.AWS_REGION }}
- name: Sync files to S3
run: |
aws s3 sync frontend/ s3://${{ secrets.S3_BUCKET_NAME }} --delete
- name: Invalidate CloudFront
run: |
aws cloudfront create-invalidation \
--distribution-id ${{ secrets.CLOUDFRONT_DISTRIBUTION_ID }} \
--paths "/*"
The paths: ['frontend/**'] filter means this workflow only fires when frontend files change — infra changes don't trigger a pointless frontend deploy.
GitHub Secrets Required
| Secret | Description |
|---|---|
AWS_ACCESS_KEY_ID |
IAM user access key |
AWS_SECRET_ACCESS_KEY |
IAM user secret key |
AWS_REGION |
e.g. us-east-1
|
S3_BUCKET_NAME |
The S3 bucket name |
CLOUDFRONT_DISTRIBUTION_ID |
CloudFront distribution ID |
Part 3: The Backend Infrastructure (Terraform)
The backend lives in a separate repo and is 100% managed by Terraform. No manual clicking in the AWS console — every resource is code.
What Terraform Provisions
Here's a summary of everything Terraform creates:
| Resource | Purpose |
|---|---|
aws_lambda_function |
Python 3.12 visitor counter function |
aws_iam_role |
Lambda execution role |
aws_iam_policy |
DynamoDB GetItem + UpdateItem permissions |
aws_dynamodb_table |
PAY_PER_REQUEST table to store the counter |
aws_apigatewayv2_api |
HTTP API with CORS for tejtandel.com
|
aws_apigatewayv2_integration |
Connects API Gateway to Lambda (AWS_PROXY) |
aws_apigatewayv2_route |
GET /visitorcounter route |
aws_apigatewayv2_stage |
prod stage with auto-deploy |
aws_lambda_permission |
Allows API Gateway to invoke Lambda |
The Lambda Function (Python 3.12)
import boto3
import json
from botocore.exceptions import ClientError
dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('visitor-counter')
def lambda_handler(event, context):
try:
response = table.update_item(
Key={'id': 'visitorcounter'},
UpdateExpression='ADD #c :inc',
ExpressionAttributeNames={'#c': 'count'},
ExpressionAttributeValues={':inc': 1},
ReturnValues='UPDATED_NEW'
)
return {
'statusCode': 200,
'headers': {
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET',
'Access-Control-Allow-Headers': 'Content-Type'
},
'body': json.dumps({'visitorcounter': int(response['Attributes']['count'])})
}
except ClientError as e:
return {'statusCode': 500, 'body': json.dumps({'error': 'Database operation failed'})}
The key operation is ADD #c :inc on the DynamoDB item — this atomically increments the counter by 1 and returns the new value in a single API call.
Terraform File Structure
infra/
├── main.tf # All AWS resources
├── provider.tf # AWS provider + S3 backend config
├── variables.tf # Variable definitions
├── outputs.tf # Output values (API URL, etc.)
└── lambda/
└── lambda_function.py
The Lambda code is zipped by Terraform using archive_file data source:
data "archive_file" "zip" {
type = "zip"
source_dir = "${path.module}/./lambda/"
output_path = "${path.module}/visitor-counter-lambdafn.zip"
}
This means you don't need any external tooling — Terraform handles packaging the Lambda on every apply.
Part 4: Infrastructure CI/CD with GitHub Actions
The infra repo has two workflows:
Deploy Infrastructure
Triggers automatically when .tf files or the Lambda code changes:
name: Deploy Infrastructure
on:
push:
branches: [ main ]
paths: [ 'infra/*.tf', 'infra/lambda/**' ]
workflow_dispatch:
jobs:
terraform:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.5.0
- uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: ${{ secrets.AWS_REGION }}
- name: Terraform Init
working-directory: infra
run: terraform init -reconfigure -backend-config="bucket=terraform-state-bucket"
- name: Terraform Format
working-directory: infra
run: terraform fmt
- name: Terraform Plan
working-directory: infra
run: terraform plan
- name: Terraform Apply
working-directory: infra
run: terraform apply -auto-approve
Terraform state is stored remotely in an S3 bucket (terraform-state-bucket), making it safe for CI/CD pipelines.
Destroy Infrastructure
A manually-triggered workflow_dispatch workflow runs terraform destroy -auto-approve — useful for tearing everything down to avoid costs when not needed.
Part 5: AWS Services Summary
| Service | Role |
|---|---|
| S3 | Hosts the static resume website files |
| CloudFront | CDN, HTTPS termination, global edge caching |
| Route 53 | Custom domain DNS (tejtandel.com) |
| ACM | Free SSL/TLS certificate |
| API Gateway | HTTP API endpoint for the visitor counter |
| Lambda | Serverless function (Python 3.12) |
| DynamoDB | NoSQL database for the counter (PAY_PER_REQUEST) |
| IAM | Least-privilege roles and policies |
| S3 (Terraform State) | Remote state storage for Terraform |
Key Lessons Learned
1. Separate your frontend and infra repos. It makes CI/CD pipelines simpler — each repo only deploys what it owns.
2. Use path filters in GitHub Actions. Without paths: ['frontend/**'], every commit to any file would trigger a frontend deploy.
3. Terraform remote state is essential for CI/CD. Local state files don't work in GitHub Actions runners — always use an S3 backend.
4. CORS must be configured in two places. I set CORS in both the API Gateway resource in Terraform and in the Lambda response headers to be safe.
5. DynamoDB PAY_PER_REQUEST is perfect here. A resume visitor counter has completely unpredictable traffic — on-demand billing means you pay only for the requests you get, which is pennies per month.
Top comments (0)