Cloud Resume with Serverless Counter

Host your resume on AWS with a live visitor counter using Lambda and DynamoDB.

Introduction

30 Second Summary

Your resume is one of the most important documents you own. Hosting it on the cloud transforms it from a static file into an interactive website that proves your technical skills to any employer who visits.

In this project, you will build a cloud-hosted resume on AWS with a real-time visitor counter powered by serverless technology.

What You'll Build

A full-stack serverless resume website using S3 for file storage, CloudFront for global HTTPS delivery, Lambda for backend logic, and DynamoDB for data storage.

By the end of this project, you'll have:

  • A CloudFront distribution delivering your resume globally with HTTPS, backed by a private S3 bucket secured with Origin Access Control.
  • A Python Lambda function with a Function URL that reads and increments a visitor count stored in DynamoDB.
  • A live resume website displaying a real-time visitor counter that updates on every page refresh.
  • Secret Mission: Set up a GitHub Actions CI/CD pipeline that automatically deploys your resume on every push to main.

Are there any prerequisites?

This project assumes you already have an AWS account with the AWS CLI installed and configured, a GitHub account, and an existing HTML/CSS resume file. You should also have a Linux device (or WSL on Windows) for running terminal commands.

If you don't have a resume ready, don't worry. The project provides a simple template you can customise later.

Set Up Your Project and Create the S3 Bucket

Your resume lives on your computer right now. To turn it into a website anyone can visit, you need to store those files in the cloud.

In this step, you'll prepare your resume files, create an S3 bucket to hold them, and upload everything. S3 is AWS's object storage service, and it will act as the home for your website's files.

In this step, get ready to:
  • Prepare your resume files with the project template.
  • Create an S3 bucket to store your resume.
  • Upload your files to S3 and verify they arrived.
Prepare your resume files

Your resume needs a specific HTML structure for this project. The template includes a footer with a placeholder for the visitor counter that you'll wire up in a later step.

  • In your code editor, open the index.html file in your project folder.
  • Replace its contents with the following template:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>My Cloud Resume</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <header>
        <h1>Your Name</h1>
        <p>Cloud Engineer | AWS Certified</p>
    </header>

    <main>
        <section>
            <h2>Experience</h2>
            <p>Replace this with your actual resume content.</p>
        </section>

        <section>
            <h2>Skills</h2>
            <ul>
                <li>AWS (S3, CloudFront, Lambda, DynamoDB)</li>
                <li>Python</li>
                <li>Infrastructure as Code</li>
                <li>CI/CD Pipelines</li>
            </ul>
        </section>
    </main>

    <footer>
        <p>This page has been viewed <span id="visitor-count">...</span> times.</p>
    </footer>
</body>
</html>

What does this code do?

This is a standard HTML resume template with three key sections.

  • The <header> contains your name and title. Replace these with your own details.
  • The <main> section holds your experience and skills. Customize these with your real resume content.
  • The <footer> has a <span id="visitor-count"> element. This placeholder will display a live visitor count once you connect it to your backend in a later step.
  • Save index.html.
  • Now open style.css in your project folder and replace its contents with:
* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

body {
    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
    line-height: 1.6;
    color: #333;
    max-width: 800px;
    margin: 0 auto;
    padding: 2rem;
}

header {
    text-align: center;
    margin-bottom: 2rem;
    padding-bottom: 1rem;
    border-bottom: 2px solid #2563eb;
}

header h1 {
    font-size: 2rem;
    color: #1e40af;
}

header p {
    color: #6b7280;
    margin-top: 0.5rem;
}

section {
    margin-bottom: 2rem;
}

section h2 {
    color: #1e40af;
    margin-bottom: 0.5rem;
    font-size: 1.25rem;
}

ul {
    list-style: none;
    padding: 0;
}

ul li {
    padding: 0.25rem 0;
    padding-left: 1rem;
    position: relative;
}

ul li::before {
    content: ">";
    position: absolute;
    left: 0;
    color: #2563eb;
}

footer {
    margin-top: 3rem;
    text-align: center;
    color: #6b7280;
    font-size: 0.875rem;
}

#visitor-count {
    font-weight: bold;
    color: #2563eb;
}

What does this stylesheet do?

This CSS gives your resume a clean, professional look. It centers the content at a max width of 800px, adds a blue accent header, and styles list items with a > marker instead of bullet points.

The #visitor-count selector at the bottom makes the counter number bold and blue so it stands out in the footer.

  • Save style.css.
  • Open index.html in your browser. You should see a centered resume with your name, a blue header border, and a footer reading "This page has been viewed ... times."

Page looks unstyled?

  • Make sure style.css is saved in the same folder as index.html. The <link> tag references style.css by filename, so both files must be in the same directory.
  • Check that the filename is exactly style.css with no capital letters or extra spaces.

Ask for help: My index.html isn't picking up the stylesheet. What should I check?

✔️ Awesome, I've got everything!

Great. Double check you've saved both files, then move on to creating your S3 bucket.

ⓧ I'd like to double check the full code

Here's what your index.html should look like:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>My Cloud Resume</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <header>
        <h1>Your Name</h1>
        <p>Cloud Engineer | AWS Certified</p>
    </header>

    <main>
        <section>
            <h2>Experience</h2>
            <p>Replace this with your actual resume content.</p>
        </section>

        <section>
            <h2>Skills</h2>
            <ul>
                <li>AWS (S3, CloudFront, Lambda, DynamoDB)</li>
                <li>Python</li>
                <li>Infrastructure as Code</li>
                <li>CI/CD Pipelines</li>
            </ul>
        </section>
    </main>

    <footer>
        <p>This page has been viewed <span id="visitor-count">...</span> times.</p>
    </footer>
</body>
</html>

And here's your style.css:

* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
}

body {
    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
    line-height: 1.6;
    color: #333;
    max-width: 800px;
    margin: 0 auto;
    padding: 2rem;
}

header {
    text-align: center;
    margin-bottom: 2rem;
    padding-bottom: 1rem;
    border-bottom: 2px solid #2563eb;
}

header h1 {
    font-size: 2rem;
    color: #1e40af;
}

header p {
    color: #6b7280;
    margin-top: 0.5rem;
}

section {
    margin-bottom: 2rem;
}

section h2 {
    color: #1e40af;
    margin-bottom: 0.5rem;
    font-size: 1.25rem;
}

ul {
    list-style: none;
    padding: 0;
}

ul li {
    padding: 0.25rem 0;
    padding-left: 1rem;
    position: relative;
}

ul li::before {
    content: ">";
    position: absolute;
    left: 0;
    color: #2563eb;
}

footer {
    margin-top: 3rem;
    text-align: center;
    color: #6b7280;
    font-size: 0.875rem;
}

#visitor-count {
    font-weight: bold;
    color: #2563eb;
}
Create your S3 bucket

An S3 bucket is a container that holds your files in the AWS cloud. You'll create one to store your resume so it can be served as a website.

Why do I need a unique bucket name?

S3 bucket names are globally unique across all AWS accounts. No two people in the world can have a bucket with the same name. Choose something like yourname-cloud-resume to make it unique to you.

Bucket names must be 3 to 63 characters, use only lowercase letters, numbers, and hyphens, and start and end with a letter or number.

  • In your terminal, navigate to the folder containing your index.html and style.css files.
  • Create your S3 bucket by running this command:
aws s3 mb s3://[[BUCKET_NAME="your-resume-bucket-name"]] --region us-east-1

What does this command do?

  • aws s3 mb stands for "make bucket." It creates a new S3 bucket in your AWS account.
  • s3:// is the prefix for all S3 bucket paths.
  • --region us-east-1 places your bucket in the US East (N. Virginia) region. This is where CloudFront works best as a default origin.

You should see a message confirming the bucket was created.

Seeing a BucketAlreadyExists error?

This means someone else in the world already owns a bucket with that name. S3 bucket names are globally unique across all AWS accounts.

  • Choose a more unique name by adding your initials, a date, or random numbers (e.g. jane-resume-2026).
  • Re-run the aws s3 mb command with your new name.

Ask for help: I keep getting a BucketAlreadyExists error when creating my S3 bucket. How do I pick a unique name?

Upload your files and verify

Now that your bucket exists, you'll upload your resume files to it.

  • Upload your resume files by running this command from the folder containing your files:
aws s3 sync . s3://[[BUCKET_NAME="your-resume-bucket-name"]] --exclude ".*"

What does this command do?

  • aws s3 sync compares your local files with what's in the bucket and uploads anything new or changed.
  • . means "the current directory." This tells the command to sync everything in your project folder.
  • --exclude ".*" skips hidden files (like .git or .DS_Store) so only your resume files get uploaded.
  • Verify the upload by listing your bucket contents:
aws s3 ls s3://[[BUCKET_NAME="your-resume-bucket-name"]]

You should see index.html and style.css listed in the output with their file sizes and timestamps.

Don't see your files listed?

  • Make sure you ran the aws s3 sync command from the same folder that contains your index.html and style.css. The . in the command means the current directory.
  • Check that you used the same bucket name in both the sync and ls commands. A typo will point to a different (or nonexistent) bucket.

Ask for help: My aws s3 ls command isn't showing the files I uploaded. What could be wrong?

Your resume files are now stored in the cloud. Next up, you'll create a CloudFront distribution to serve your resume over HTTPS with fast global delivery.

Create CloudFront Distribution with OAC

Your resume files are sitting in an S3 bucket, but that bucket is private. Nobody can access those files directly through the internet.

In this step, you will create a CloudFront distribution that serves your resume globally over HTTPS. CloudFront acts as a secure front door, fetching your files from S3 and delivering them to visitors anywhere in the world.

In this step, get ready to:
  • Create an Origin Access Control (OAC) to secure S3 access.
  • Create a CloudFront distribution with HTTPS redirect.
  • Update the S3 bucket policy to allow only CloudFront access.
Create an Origin Access Control

An Origin Access Control (OAC) tells CloudFront how to authenticate requests to your S3 bucket. With OAC, your bucket stays completely private while CloudFront signs every request with sigv4 credentials.

  • Create the OAC by running this command in your terminal:
aws cloudfront create-origin-access-control --origin-access-control-config Name="resume-oac",SigningProtocol=sigv4,SigningBehavior=always,OriginAccessControlOriginType=s3

What does this command do?

  • Name="resume-oac" gives your OAC a friendly name you can identify later.
  • SigningProtocol=sigv4 tells CloudFront to use AWS Signature Version 4 to sign requests to S3.
  • SigningBehavior=always means CloudFront signs every single request to S3, not just some.
  • OriginAccessControlOriginType=s3 specifies this OAC is for an S3 origin.
  • In the JSON output, find the Id field inside OriginAccessControl. Copy this value and save it somewhere. You will need it in the next substep.

Save the OAC ID

You can save it as an environment variable for convenience:

OAC_ID="your-oac-id-here"

Seeing an error about invalid parameters?

  • Make sure there are no spaces around the commas in the --origin-access-control-config value. The entire config string must be one continuous value with no spaces.
  • Verify your AWS CLI is configured and authenticated by running aws sts get-caller-identity.

Still stuck? Help me troubleshoot the create-origin-access-control command

Create the CloudFront distribution

CloudFront needs a configuration file describing where to fetch content from, how to handle HTTPS, and which file to serve by default. You will create this config as a JSON file.

  • Create a file called distribution-config.json in your project directory with the following content:
{
    "CallerReference": "resume-distribution-1",
    "Origins": {
        "Quantity": 1,
        "Items": [
            {
                "Id": "S3-your-resume-bucket",
                "DomainName": "[[BUCKET_NAME=\"your-resume-bucket\"]].s3.amazonaws.com",
                "S3OriginConfig": {
                    "OriginAccessIdentity": ""
                },
                "OriginAccessControlId": "[[OAC_ID=\"your OAC ID from the previous step\"]]"
            }
        ]
    },
    "DefaultCacheBehavior": {
        "TargetOriginId": "S3-your-resume-bucket",
        "ViewerProtocolPolicy": "redirect-to-https",
        "AllowedMethods": {
            "Quantity": 2,
            "Items": ["GET", "HEAD"],
            "CachedMethods": {
                "Quantity": 2,
                "Items": ["GET", "HEAD"]
            }
        },
        "DefaultTTL": 86400,
        "MinTTL": 0,
        "MaxTTL": 31536000,
        "Compress": true,
        "ForwardedValues": {
            "QueryString": false,
            "Cookies": {
                "Forward": "none"
            }
        }
    },
    "DefaultRootObject": "index.html",
    "Comment": "CloudFront distribution for resume",
    "Enabled": true
}

What does this config do?

  • Origins tells CloudFront where to fetch files from. You point it at your S3 bucket and attach the OAC ID so it can authenticate.
  • OriginAccessIdentity is left empty because you are using OAC instead of the legacy OAI method.
  • ViewerProtocolPolicy: "redirect-to-https" automatically redirects any HTTP visitor to HTTPS for a secure connection.
  • DefaultRootObject: "index.html" means visiting the root URL serves your resume without needing to type /index.html.

Why OAC over public S3 hosting?

OAC is the current AWS-recommended approach. It keeps your S3 bucket fully private (no public access) and serves content exclusively through CloudFront. The older method (Origin Access Identity) is legacy and not recommended for new distributions.

  • Create the distribution by running this command:
aws cloudfront create-distribution --distribution-config file://distribution-config.json

What should I see?

The command returns a large JSON response. Look for two values you need to save:

  • Distribution.Id is your distribution ID (something like E1A2B3C4D5E6F7).
  • Distribution.DomainName is your CloudFront URL (something like d111111abcdef8.cloudfront.net).
  • Copy and save both the distribution ID and domain name. You will use these throughout the rest of the project.

Getting a MalformedInput or validation error?

  • Double-check that your OAC_ID in the JSON file matches the exact ID from the previous step.
  • Verify your bucket name in DomainName and TargetOriginId matches the bucket you created in Step 1.
  • Ensure the JSON is valid. Missing commas or brackets will cause parsing failures.

Still stuck? Help me debug my CloudFront distribution creation

Update the S3 bucket policy and wait for deployment

Your CloudFront distribution exists, but S3 does not know to trust it yet. You need to add a bucket policy that grants CloudFront permission to read your files through the OAC.

  • Create a file called bucket-policy.json with the following content:
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "AllowCloudFrontServicePrincipal",
            "Effect": "Allow",
            "Principal": {
                "Service": "cloudfront.amazonaws.com"
            },
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::[[BUCKET_NAME=\"your-resume-bucket\"]]/*",
            "Condition": {
                "StringEquals": {
                    "AWS:SourceArn": "arn:aws:cloudfront::[[ACCOUNT_ID=\"your AWS account ID\"]]:distribution/[[DIST_ID=\"your distribution ID\"]]"
                }
            }
        }
    ]
}

What does this policy do?

  • Principal is set to cloudfront.amazonaws.com so only the CloudFront service can use this permission.
  • Action is s3:GetObject which gives read-only access to your files.
  • The Condition restricts access to only your specific CloudFront distribution. No other distribution can read your bucket, even if they also use the CloudFront service principal.
  • Get your AWS account ID by running:
aws sts get-caller-identity --query 'Account' --output text
  • Replace the placeholders in bucket-policy.json with your actual bucket name, account ID, and distribution ID.
  • Apply the bucket policy by running:
aws s3api put-bucket-policy --bucket [[BUCKET_NAME="your-resume-bucket"]] --policy file://bucket-policy.json
  • Save bucket-policy.json and confirm the command completes with no error output.

How long does deployment take?

After creating a CloudFront distribution, it takes 5 to 15 minutes to deploy globally. The distribution status changes from InProgress to Deployed once it is ready.

You can check the status by running:

aws cloudfront get-distribution --id [[DIST_ID="your distribution ID"]] --query 'Distribution.Status'
  • Wait until the status shows Deployed, then visit your CloudFront URL in a browser:
https://[[CF_DOMAIN="your-distribution-domain.cloudfront.net"]]
  • You should see your resume displayed with a secure HTTPS connection.

Seeing an Access Denied error?

  • Make sure the distribution status is Deployed before testing. If it still says InProgress, wait a few more minutes.
  • Verify that your bucket-policy.json uses the correct distribution ID and account ID.
  • Double-check the Resource field ends with /* to grant access to all objects in the bucket.

Still stuck? Help me fix my CloudFront Access Denied error

Your resume is now live and globally distributed over HTTPS. Next up, you will build a serverless visitor counter that tracks how many people view your resume.

Build the Visitor Counter Backend

Your resume is live on CloudFront with HTTPS. Now you need to make it interactive.

Every resume site benefits from a visitor counter. It proves your backend skills and shows the site is actually being used. In this step, you will build a serverless API that counts page views using DynamoDB for storage and Lambda for compute.

In this step, get ready to:
  • Create a DynamoDB table to store the visitor count.
  • Write a Python Lambda function that atomically increments the counter.
  • Add a public Function URL to your Lambda and test it with curl.
Create a DynamoDB table

Your visitor counter needs somewhere to store its data. DynamoDB is a serverless database that charges nothing for low-traffic usage under the Always Free tier.

  • Create the resume-visitors table by running this command in your terminal:
aws dynamodb create-table \
    --table-name resume-visitors \
    --attribute-definitions AttributeName=id,AttributeType=S \
    --key-schema AttributeName=id,KeyType=HASH \
    --billing-mode PAY_PER_REQUEST

What does this command do?

  • --table-name resume-visitors names your table.
  • --attribute-definitions defines a single partition key called id of type String.
  • --billing-mode PAY_PER_REQUEST means you only pay per read/write. For a personal resume site, this stays well within the free tier.
  • Wait a few seconds for the table to become active, then seed it with an initial counter item by running:
aws dynamodb put-item \
    --table-name resume-visitors \
    --item '{"id": {"S": "visitor-counter"}, "visitor_count": {"N": "0"}}'

What does this command do?

This inserts a single item into your table with the partition key id set to visitor-counter and a numeric visitor_count attribute starting at 0.

Your Lambda function will increment this value every time someone visits your resume.

  • Verify the table and item exist by running:
aws dynamodb get-item \
    --table-name resume-visitors \
    --key '{"id": {"S": "visitor-counter"}}'

You should see JSON output showing your visitor-counter item with visitor_count set to 0.

Table not found or empty response?

  • Make sure you ran the create-table command first and waited a moment for it to finish creating.
  • Check that the table name in your put-item command matches exactly: resume-visitors.
  • Confirm your CLI region matches by running aws configure get region and checking it returns us-east-1.

Still stuck? Help me troubleshoot my DynamoDB table creation

Create the Lambda function

Now you need compute. Lambda runs your code without managing servers. It already includes the boto3 library for interacting with AWS services, so you can talk to DynamoDB with zero extra dependencies.

  • Open the Functions page of the Lambda console in your browser.
  • Click Create function.
  • Select Author from scratch.
  • For Function name, enter resume-visitor-counter.
  • For Runtime, select Python 3.13.
  • Leave the architecture and permissions at their defaults.
  • Click Create function.

Lambda creates your function with a default execution role. You will land on the function's code editor page.

  • In the Code source pane, select all the existing code and replace it with the following:
import json
import boto3

dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('resume-visitors')


def lambda_handler(event, context):
    response = table.update_item(
        Key={'id': 'visitor-counter'},
        UpdateExpression='SET visitor_count = if_not_exists(visitor_count, :start) + :inc',
        ExpressionAttributeValues={
            ':inc': 1,
            ':start': 0
        },
        ReturnValues='UPDATED_NEW'
    )

    visitor_count = int(response['Attributes']['visitor_count'])

    return {
        'statusCode': 200,
        'headers': {
            'Content-Type': 'application/json',
            'Access-Control-Allow-Origin': '*',
            'Access-Control-Allow-Methods': 'GET',
            'Access-Control-Allow-Headers': 'Content-Type'
        },
        'body': json.dumps({'visitor_count': visitor_count})
    }

What does this code do?

  • boto3.resource('dynamodb') connects to DynamoDB using the credentials from the Lambda execution role.
  • table.update_item uses an atomic counter pattern. The if_not_exists expression initializes the counter to 0 if it does not exist, then adds 1. This is safe even under concurrent requests.
  • ReturnValues='UPDATED_NEW' tells DynamoDB to return the new value after the increment.
  • The response includes CORS headers (Access-Control-Allow-Origin: *) so your CloudFront-hosted resume can call this API from a different domain.
  • Click Deploy to save and deploy your function code.

✔️ Awesome, I've got everything!

Great. Make sure you clicked Deploy and see the green "Changes deployed" banner, then continue below.

ⓧ I'd like to double check the full code

import json
import boto3

dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('resume-visitors')


def lambda_handler(event, context):
    response = table.update_item(
        Key={'id': 'visitor-counter'},
        UpdateExpression='SET visitor_count = if_not_exists(visitor_count, :start) + :inc',
        ExpressionAttributeValues={
            ':inc': 1,
            ':start': 0
        },
        ReturnValues='UPDATED_NEW'
    )

    visitor_count = int(response['Attributes']['visitor_count'])

    return {
        'statusCode': 200,
        'headers': {
            'Content-Type': 'application/json',
            'Access-Control-Allow-Origin': '*',
            'Access-Control-Allow-Methods': 'GET',
            'Access-Control-Allow-Headers': 'Content-Type'
        },
        'body': json.dumps({'visitor_count': visitor_count})
    }

Next, you need to give your Lambda function permission to read and write to DynamoDB.

  • Click the Configuration tab.
  • Select Permissions in the left sidebar.
  • Under Execution role, click the role name link. This opens IAM in a new tab.
  • Click Add permissions, then Attach policies.
  • Search for AmazonDynamoDBFullAccess and select it.
  • Click Add permissions.

Why DynamoDB full access?

For a personal project, AmazonDynamoDBFullAccess is quick to set up. In production, you would create a custom policy scoped only to the resume-visitors table with dynamodb:UpdateItem and dynamodb:GetItem actions.

Can't find the role link?

Make sure you are on the Configuration tab (not the Code tab) and have selected Permissions from the sidebar. The role name appears as a blue hyperlink under Execution role.

Need more help? Help me attach a DynamoDB policy to my Lambda execution role

Add a Function URL and test

Your Lambda function is ready, but it has no way to receive HTTP requests from a browser. A Function URL gives your function a dedicated HTTPS endpoint without needing API Gateway.

Why Function URL instead of API Gateway?

Function URLs are simpler for single-endpoint use cases. Your project has exactly one API (the counter), so a Function URL avoids the extra configuration and cost of API Gateway.

  • Return to your Lambda function in the console.
  • Click the Configuration tab.
  • Select Function URL in the left sidebar.
  • Click Create function URL.
  • For Auth type, select NONE.
  • Select Configure cross-origin resource sharing (CORS).
  • Under Allow origins, enter *.
  • Click Save.

Your Function URL appears in the Function overview section at the top of the page. Copy it and save it somewhere. You will need it in the next step.

What does Auth type NONE mean?

Setting Auth type to NONE allows anyone with the URL to invoke your function. This is appropriate for a public visitor counter.

When you create a Function URL with NONE in the console, Lambda automatically creates the required resource-based policy for you.

  • Test the Function URL by running this in your terminal:
curl [[FUNCTION_URL="https://your-function-url.lambda-url.us-east-1.on.aws"]]

You should see a JSON response like:

{"visitor_count": 1}
  • Run the curl command again. The count should increment to 2.

Getting an Internal Server Error?

  • Check that you attached the DynamoDB policy to the Lambda execution role. Without it, the function cannot access the table.
  • Verify the table name in your code matches exactly: resume-visitors.
  • Make sure your Lambda function and DynamoDB table are both in us-east-1.

Need help? Help me debug my Lambda Function URL returning an error

Your serverless backend is complete. You have a DynamoDB table storing visitor counts and a Lambda function with a public HTTPS endpoint that increments the counter on every request. Next, you will wire this API into your resume's HTML so visitors see the count on the page.

Wire the Counter Into Your Resume

Your backend is ready. The Lambda function increments the counter in DynamoDB and returns the new value every time it receives a request.

Now you need to connect the frontend to the backend. In this step, you will add JavaScript to your resume that calls the Lambda Function URL on page load, then deploy the updated file to S3 and clear the CloudFront cache so visitors see the change immediately.

In this step, get ready to:
  • Add JavaScript to your resume that fetches the visitor count from your Lambda Function URL.
  • Deploy the updated resume to S3 and invalidate the CloudFront cache.
  • Verify the live visitor counter increments on each page refresh.
Add visitor counter JavaScript to your resume

Your index.html already has a <span id="visitor-count"> element in the footer. This is where the live count will appear. You need to add a script that fetches the count from your Lambda Function URL and fills in that span.

  • Open index.html in your code editor.
  • Find the closing </body> tag at the bottom of the file.
  • Add the following <script> block directly above </body>:
    <script>
        async function updateVisitorCount() {
            try {
                const response = await fetch('[[FUNCTION_URL="your Lambda Function URL"]]');
                const data = await response.json();
                document.getElementById('visitor-count').textContent = data.visitor_count;
            } catch (error) {
                console.error('Error fetching visitor count:', error);
                document.getElementById('visitor-count').textContent = 'N/A';
            }
        }
        updateVisitorCount();
    </script>

What does this code do?

  • updateVisitorCount() is an async function that sends a GET request to your Lambda Function URL using fetch.
  • When the response arrives, it parses the JSON body and sets the text content of the visitor-count span to the returned visitor_count value.
  • The catch block handles network failures gracefully by displaying N/A instead of a broken page.
  • The function is called immediately after its definition, so it runs as soon as the page loads.
  • Save index.html.

Why fetch on page load?

Every time a visitor opens your resume, the browser fires a request to your Lambda Function URL. Lambda increments the DynamoDB counter and returns the new total. This means the count you see is always live and up to date.

Not sure which URL to paste?

Your Lambda Function URL looks like https://<url-id>.lambda-url.us-east-1.on.aws. You saved this in the previous step when you created the Function URL in the AWS Console.

Help me find my Lambda Function URL in the AWS Console.

Upload updated files and invalidate the CloudFront cache

Your local file is updated, but S3 still has the old version. You need to sync the new file to S3, then tell CloudFront to clear its cache so it picks up the change.

  • Sync your updated files to S3 by running this command from your project folder:
aws s3 sync . s3://[[BUCKET_NAME="your-resume-bucket"]] --exclude ".*"

What does this command do?

  • aws s3 sync compares your local files to S3 and uploads only the ones that have changed.
  • --exclude ".*" skips hidden files (like .DS_Store) so only your resume files are uploaded.
  • Verify the upload completed by running:
aws s3 ls s3://[[BUCKET_NAME="your-resume-bucket"]]

You should see index.html and style.css listed with updated timestamps on index.html.

Sync completed but no files listed?

Make sure you are running the command from the folder that contains your index.html and style.css files. If you are in a parent folder, the sync uploads nothing because there are no matching files.

Help me figure out why my S3 sync is not uploading files.

CloudFront caches your files at edge locations around the world. Even though S3 has the new version, CloudFront will keep serving the old cached copy until you invalidate it.

  • Invalidate the CloudFront cache by running:
aws cloudfront create-invalidation --distribution-id [[DIST_ID="your CloudFront distribution ID"]] --paths "/*"

What does this command do?

The --paths "/*" flag tells CloudFront to clear its cache for all files. This forces CloudFront to fetch the latest version from S3 the next time someone visits your site.

You should see a JSON response with "Status": "InProgress" confirming the invalidation was created. It typically completes within a few minutes.

Seeing an access denied error?

Double check that the distribution ID matches the one you saved when you created your CloudFront distribution in the previous step. You can find it in the AWS Console under CloudFront > Distributions.

Help me find my CloudFront distribution ID.

Verify the live visitor counter
  • Open your CloudFront URL in a browser:
https://[[CLOUDFRONT_DOMAIN="your-distribution-domain.cloudfront.net"]]
  • Look at the footer of your resume. You should see a number where the ... placeholder used to be.
  • Refresh the page a few times. The visitor count should increment by 1 with each refresh.

Counter still showing dots or N/A?

  • Open your browser's developer console (Cmd+Option+J on macOS or Ctrl+Shift+J on Windows) and check for error messages. A CORS error means the Lambda Function URL's CORS settings need updating.
  • Make sure the URL in your fetch() call starts with https:// and matches your Function URL exactly.
  • If the invalidation is still in progress, wait 2-3 minutes and refresh again.

Help me debug why my visitor counter is not showing a number.

✔️ Awesome, I've got everything!

Great. Double check you have saved index.html and that your live site is showing an incrementing visitor count.

ⓧ I'd like to double check the full code

Here is the complete index.html file with the visitor counter script:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>My Cloud Resume</title>
    <link rel="stylesheet" href="style.css">
</head>
<body>
    <header>
        <h1>Your Name</h1>
        <p>Cloud Engineer | AWS Certified</p>
    </header>

    <main>
        <section>
            <h2>Experience</h2>
            <p>Replace this with your actual resume content.</p>
        </section>

        <section>
            <h2>Skills</h2>
            <ul>
                <li>AWS (S3, CloudFront, Lambda, DynamoDB)</li>
                <li>Python</li>
                <li>Infrastructure as Code</li>
                <li>CI/CD Pipelines</li>
            </ul>
        </section>
    </main>

    <footer>
        <p>This page has been viewed <span id="visitor-count">...</span> times.</p>
    </footer>

    <script>
        async function updateVisitorCount() {
            try {
                const response = await fetch('[[FUNCTION_URL="your Lambda Function URL"]]');
                const data = await response.json();
                document.getElementById('visitor-count').textContent = data.visitor_count;
            } catch (error) {
                console.error('Error fetching visitor count:', error);
                document.getElementById('visitor-count').textContent = 'N/A';
            }
        }
        updateVisitorCount();
    </script>
</body>
</html>

Your cloud resume is live and fully functional. Every visitor now triggers a serverless chain from CloudFront to Lambda to DynamoDB and back. Next up, the Secret Mission takes this further with automated deployments using GitHub Actions.

Secret mission

GitHub Actions CI/CD Pipeline

Your resume deploys manually right now. Every time you change a line of HTML, you re-run aws s3 sync and invalidate the cache by hand. In production, teams automate this with CI/CD pipelines that deploy on every push. Can you wire up GitHub Actions to do the same?

Clean Up Your Resources

Clean Up Your Resources

Decide whether to keep your resources running, pause them to come back later, or delete them entirely. All services used in this project fall within the AWS Always Free tier for a personal resume site, so there are no ongoing costs expected.

Resources you used:

  • S3 bucket (your-resume-bucket).
  • CloudFront distribution with OAC.
  • DynamoDB table (resume-visitors).
  • Lambda function with Function URL.
  • IAM role (github-actions-deploy-role).
  • IAM OIDC identity provider for GitHub Actions.
  • GitHub repository.
  • Local project files on your Desktop.

Keep everything running

No action needed. Choose this if you are still actively building or want to keep your resume live.

  • All AWS resources stay within the Always Free tier for a personal resume site.
  • Lambda: 1 million free requests per month.
  • DynamoDB: 25 GB storage free.
  • CloudFront: 1 million requests and 100 GB transfer per month on the Free plan.
  • S3: covered by CloudFront's 5 GB storage credit.
  • Your GitHub Actions workflow will continue to deploy on each push to main.

Pause - I'll come back to this later

These serverless resources only incur costs when actively used. There is nothing to pause because AWS does not charge for idle Lambda functions, dormant DynamoDB tables, or a CloudFront distribution with no traffic.

  • Your resume stays live at its CloudFront URL, but costs nothing at low traffic levels.
  • Come back anytime and pick up where you left off.

Delete - I don't want to use this again

Remove all project resources and start fresh. Follow each step below in order.

  • Delete the S3 bucket and all its contents by running:
  • This removes the bucket and every file inside it in one command.
aws s3 rb s3://[[BUCKET_NAME="your-resume-bucket"]] --force
  • Delete the CloudFront distribution. You must disable it first, then delete it:
  • Open the CloudFront console at https://console.aws.amazon.com/cloudfront/.
  • Select the checkbox next to your distribution.
  • Click Disable, then confirm.
  • Wait until the Last modified column shows a new timestamp (this may take a few minutes).
  • Select the distribution again and click Delete, then confirm.
  • Delete the Lambda function by running:
aws lambda delete-function --function-name [[LAMBDA_NAME="your-lambda-function-name"]]
  • Delete the DynamoDB table by running:
aws dynamodb delete-table --table-name resume-visitors
  • Delete the IAM role. You must detach its policies first, then delete the role:
aws iam detach-role-policy --role-name github-actions-deploy-role --policy-arn [[POLICY_ARN="arn:aws:iam::YOUR_ACCOUNT_ID:policy/your-policy-name"]]
aws iam delete-role --role-name github-actions-deploy-role
  • Delete the IAM OIDC identity provider by running:
aws iam delete-open-id-connect-provider --open-id-connect-provider-arn [[OIDC_ARN="arn:aws:iam::YOUR_ACCOUNT_ID:oidc-provider/token.actions.githubusercontent.com"]]
  • Delete your GitHub repository if you no longer need it. Go to Settings at the bottom of your repository page, scroll to the Danger Zone, and click Delete this repository.
  • Delete the local project files from your Desktop by running:
rm -rf ~/Desktop/cloud-resume

That's a Wrap!

That's a Wrap!

Nice work! You just built a production-ready cloud resume on AWS with a real-time serverless visitor counter.

You've learned how to:

  • Hosted a static website on S3 and delivered it globally with CloudFront using Origin Access Control for secure HTTPS delivery.
  • Built a serverless visitor counter API using a Lambda Function URL and DynamoDB atomic counters that increment on every page load.
  • Wired frontend JavaScript to fetch and display live visitor counts from your serverless backend.
  • (Secret Mission) Set up a GitHub Actions CI/CD pipeline with OIDC authentication for automated deployments on every push to main.

Ready to quiz yourself?