DEV Community

Tej Tandel
Tej Tandel

Posted on

How I Built My AWS Cloud Resume Challenge — A Complete Walkthrough

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)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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:

  1. Syncs all files to the S3 bucket
  2. 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 "/*"
Enter fullscreen mode Exit fullscreen mode

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'})}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)