Build a Serverless Cloud Resume on AWS
Deploy a portfolio site on AWS with a serverless backend and CI/CD pipeline.
Introduction
30 Second Summary
Every cloud job application asks you to prove your skills. But a bullet point on a resume that says "I know AWS" is easy to write and impossible to verify.
In this project, you will build a serverless portfolio website on AWS that proves your cloud skills by being itself a working demonstration of production architecture. You will combine six AWS services into one cohesive system with automated deployments and a live visitor counter.
What You'll Build
A responsive portfolio website hosted entirely on AWS serverless infrastructure, complete with a visitor counter API, global CDN distribution, and automated CI/CD deployments triggered on every push to main.
By the end of this project, you'll have:
- A live portfolio website served globally through CloudFront with secure S3 origin access.
- A working serverless visitor counter powered by API Gateway, Lambda, and DynamoDB.
- An automated CI/CD pipeline using GitHub Actions with OIDC federation for credential-free deployments.
- Secret Mission: A custom domain with Route 53 DNS and an SSL certificate from ACM.
Are there any prerequisites?
You will need an AWS account with the AWS CLI configured, a GitHub account, and VS Code installed. Basic familiarity with navigating the AWS console and an understanding of Lambda and DynamoDB fundamentals will help you move faster.
Set Up Git and Create Your Project Repository
Every file you create in this project needs a safe home that tracks changes and enables automated deployments.
In this step, you will install Git, create a GitHub repository, clone it to your machine, and scaffold the folder structure for the entire project.
In this step, get ready to:
- Install and configure Git with your identity.
- Create and clone a GitHub repository.
- Scaffold the project folder structure and push it to GitHub.
Install Git and configure your identity
Git is the version control system that tracks every change you make to your project files. It also powers GitHub Actions, the CI/CD service you will set up later.
- Open your terminal. Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type Terminal (macOS) or PowerShell (Windows) and press Enter to open it.
- Check whether Git is already installed by running this command:
git --version
✔️ I see a version number
Git is already installed. You are ready to configure your identity below.
ⓧ Command not found
Git is not installed yet. Follow the instructions for your operating system below.
Windows
- Open the Git for Windows download page in your browser:
- https://git-scm.com/download/win
- The download starts automatically. Run the installer once it finishes downloading.
- Accept the default options on each screen of the installer wizard and click Next through each step until it completes.
- Close and reopen PowerShell after installation, then run git --version again to confirm it works.
macOS
- Install Git via Xcode Command Line Tools by running:
xcode-select --install
- A dialog will appear asking you to confirm the installation. Click Install and wait for it to complete.
- Once finished, run git --version again to confirm it works.
Now configure Git with your name and email. These identify you as the author of every commit.
- Set your Git username by running this command (replace the placeholder with your actual name):
git config --global user.name "[[YOURNAME="Your Name"]]"
- Set your Git email by running this command (use the same email as your GitHub account):
git config --global user.email "[[YOUREMAIL="your.email@example.com"]]"
Why does Git need my name and email?
Every Git commit includes an author name and email. GitHub uses this email to link commits to your GitHub profile. If the email does not match your GitHub account, your commits will appear as unlinked on the repository.
Git command not found after installing?
On Windows, make sure you closed and reopened PowerShell after the installer finished. The installer adds Git to your system PATH, but open terminals do not pick up the change until restarted.
On macOS, the Xcode tools dialog may take several minutes. Wait until it says Install complete before trying again.
Help me troubleshoot my Git installation
Create a GitHub repository and clone it locally
Your GitHub repository will store all of your project code and trigger automated deployments whenever you push changes.
- Open github.com in your browser and sign in.
- Click the + icon in the top-right corner of the page.
- Select New repository.
- Enter cloud-resume in the Repository name field.
- Select Public for the visibility.
- Check the box for Add a README file.
- Click Create repository.
GitHub creates the repository and takes you to its main page. You will see a single README.md file.
- Click the green Code button on the repository page.
- Make sure the HTTPS tab is selected.
- Copy the URL shown (it will look like https://github.com/your-username/cloud-resume.git).
Now clone the repository to your local machine.
- Move to your Desktop by running this command:
cd ~/Desktop
- Clone your repository by running this command (paste the URL you copied):
git clone [[YOUR_REPO_URL="https://github.com/your-username/cloud-resume.git"]]
- Move into the cloned project folder by running:
cd cloud-resume
What did git clone do?
The git clone command downloaded a full copy of your GitHub repository to your computer. The cloud-resume folder on your Desktop is now linked to the remote repository on GitHub.
Now open the project in VS Code.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- In VS Code, click File in the top menu bar.
- Click Open Folder.
- Navigate to your Desktop, select the cloud-resume folder, and click Open.
You should see the cloud-resume folder open in the VS Code Explorer sidebar with the README.md file listed.
Don't see the folder in the sidebar?
Make sure you selected the cloud-resume folder itself, not the Desktop. You should see CLOUD-RESUME as the top-level name in the Explorer sidebar.
Help me open my project in VS Code
Create the project folder structure
Your project needs a clear folder structure from the start. The root-level files hold the website code, the lambda/ directory holds the serverless function, and .github/workflows/ holds the CI/CD pipeline definition.
- Open the integrated terminal in VS Code by pressing Ctrl+` (backtick key, same on macOS and Windows).
- Create the website files and directories by running these commands:
touch index.html style.css script.js
mkdir -p lambda
touch lambda/lambda_function.py
mkdir -p .github/workflows
touch .github/workflows/deploy.yml
What does this create?
- index.html, style.css, script.js are the three website files that will live in your S3 bucket.
- lambda/lambda_function.py is where your visitor counter function code will go.
- .github/workflows/deploy.yml is the GitHub Actions pipeline that will automate deployments.
- Confirm the files exist by running:
ls
You should see index.html, style.css, script.js, lambda, and README.md in the output.
Hidden folders on macOS
The .github folder starts with a dot, which makes it hidden by default. Run ls -a to see hidden files, or just trust that VS Code shows it in the Explorer sidebar.
Now commit and push this initial structure to GitHub.
- Stage all new files by running:
git add .
- Create your first commit by running:
git commit -m "Add initial project structure"
- Push the commit to GitHub by running:
git push
What did these three commands do?
git add . stages every new and modified file, telling Git you want them in the next commit.
git commit saves a snapshot of those staged files with a message describing the change.
git push uploads your local commits to the remote repository on GitHub so the code is backed up and visible online.
- Refresh your GitHub repository page in the browser. You should see all your new files and folders listed.
Push rejected or authentication failed?
GitHub no longer accepts passwords for Git operations. If you see an authentication error, GitHub will prompt you to sign in via a browser window or use a personal access token.
If the push is rejected because of a mismatched branch, run git pull --rebase first, then try git push again.
Help me fix my git push error
✔️ Awesome, I've got everything!
Great. Double check you have saved all files and that your GitHub repo shows the new structure.
ⓧ I'd like to double check the full code
At this point, all files are empty placeholders. Your repository should contain the following file and folder structure:
- index.html (empty)
- style.css (empty)
- script.js (empty)
- lambda/lambda_function.py (empty)
- .github/workflows/deploy.yml (empty)
- README.md (auto-generated by GitHub)
Your project skeleton is on GitHub and ready for code. Next up, you will build the actual portfolio website with HTML, CSS, and JavaScript.
Build the Static Portfolio Website
Your project structure is in place with empty files waiting to be filled. Now it is time to build the actual portfolio that visitors will see.
A portfolio website needs three layers: structure (HTML), visual design (CSS), and interactivity (JavaScript). In this step, you will build all three layers from scratch, creating a professional-looking site that showcases your cloud projects and includes a visitor counter placeholder.
In this step, get ready to:
- Create a professional HTML portfolio page with a hero section, project cards, and skills display.
- Style the portfolio with responsive CSS using Grid layout and a clean colour scheme.
- Add a JavaScript visitor counter that will connect to your serverless backend later.
Create the HTML portfolio structure
Your portfolio page needs a clear visual hierarchy. A hero section at the top introduces you, a project grid shows off your work, and a skills section lists your technical abilities.
- In VS Code, open index.html from the Explorer sidebar.
- Replace the empty file with the following HTML:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cloud Resume | AWS Portfolio</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<header class="hero">
<div class="hero-content">
<h1>Hi, I'm [Your Name]</h1>
<p class="subtitle">Cloud Engineer | AWS | Data Analytics | AI Workflows</p>
<p class="visitor-count">Visitors: <span id="visitor-count">...</span></p>
</div>
</header>
<main>
<section class="projects" id="projects">
<h2>Projects</h2>
<div class="project-grid">
<div class="project-card">
<h3>Three-Tier Web Application</h3>
<p>Built a scalable web app using CloudFront, Lambda, API Gateway, and DynamoDB on AWS.</p>
<div class="tags">
<span class="tag">AWS</span>
<span class="tag">Serverless</span>
<span class="tag">CloudFront</span>
</div>
</div>
<div class="project-card">
<h3>Serverless Analytics Pipeline</h3>
<p>Designed a data pipeline using Athena, S3, and QuickSight for business intelligence dashboards.</p>
<div class="tags">
<span class="tag">Athena</span>
<span class="tag">QuickSight</span>
<span class="tag">S3</span>
</div>
</div>
<div class="project-card">
<h3>AI Workflow Automation</h3>
<p>Automated document processing using Lambda and AI services for intelligent data extraction.</p>
<div class="tags">
<span class="tag">AI/ML</span>
<span class="tag">Lambda</span>
<span class="tag">Automation</span>
</div>
</div>
<div class="project-card">
<h3>Cloud Resume (This Site)</h3>
<p>This portfolio itself: S3, CloudFront OAC, Lambda visitor counter, GitHub Actions CI/CD.</p>
<div class="tags">
<span class="tag">S3</span>
<span class="tag">CI/CD</span>
<span class="tag">OIDC</span>
</div>
</div>
</div>
</section>
<section class="skills" id="skills">
<h2>Skills</h2>
<div class="skills-grid">
<div class="skill-category">
<h3>Cloud & Infrastructure</h3>
<ul>
<li>AWS (S3, CloudFront, Lambda, DynamoDB, API Gateway)</li>
<li>IAM & Security Best Practices</li>
<li>Serverless Architecture</li>
<li>CI/CD with GitHub Actions</li>
</ul>
</div>
<div class="skill-category">
<h3>Data & Analytics</h3>
<ul>
<li>Amazon Athena & QuickSight</li>
<li>SQL & NoSQL Databases</li>
<li>Data Pipeline Design</li>
<li>Business Intelligence Dashboards</li>
</ul>
</div>
</div>
</section>
</main>
<footer>
<p>Built with AWS S3, CloudFront, Lambda, API Gateway & DynamoDB</p>
<p>Deployed via GitHub Actions with OIDC Federation</p>
</footer>
<script src="script.js"></script>
</body>
</html>
What does this HTML do?
The page is broken into four major sections:
- The <header class="hero"> creates the banner at the top with your name, tagline, and a <span id="visitor-count"> element that JavaScript will update later.
- The <section class="projects"> holds four project cards inside a project-grid div. CSS will turn this into a responsive grid layout.
- The <section class="skills"> organises your technical skills into categories.
- The <script src="script.js"> tag at the bottom loads your JavaScript after the page content renders.
- Save index.html.
- Open the file in your browser by double-clicking index.html in File Explorer (Windows) or Finder (macOS). You should see your name, project titles, and skills rendered as plain unstyled text.
See a blank page?
- Make sure you saved the file after pasting the code.
- Confirm the file is named exactly index.html (not index.html.txt).
- Check that the file is in the root of your cloud-resume folder, not inside a subfolder.
Why is my index.html showing a blank page in the browser?
Style the portfolio with CSS
The unstyled page works but looks rough. CSS will transform it into a polished portfolio with a gradient hero banner, card-based project grid, and responsive layout that adapts to mobile screens.
- Open style.css in VS Code and paste the following base styles:
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, sans-serif;
line-height: 1.6;
color: #333;
background-color: #f8f9fa;
}
.hero {
background: linear-gradient(135deg, #232f3e 0%, #1a73e8 100%);
color: white;
padding: 80px 20px;
text-align: center;
}
.hero-content h1 {
font-size: 2.5rem;
margin-bottom: 10px;
}
.subtitle {
font-size: 1.2rem;
opacity: 0.9;
margin-bottom: 15px;
}
.visitor-count {
font-size: 0.9rem;
opacity: 0.8;
}
main {
max-width: 1100px;
margin: 0 auto;
padding: 40px 20px;
}
section {
margin-bottom: 60px;
}
h2 {
font-size: 1.8rem;
margin-bottom: 30px;
color: #232f3e;
}
What do these styles do?
- The * selector resets all browser default margins and padding so your layout starts from a clean slate.
- The .hero class creates the dark-to-blue gradient banner using #232f3e (AWS dark navy) and #1a73e8 (bright blue).
- The main element is capped at 1100px and centred, keeping content readable on wide screens.
- Save style.css and refresh your browser.
- You should see a gradient hero banner at the top with white centred text. The rest of the page has clean typography on a light grey background.
Hero section still looks unstyled?
- Confirm style.css is in the same folder as index.html. The <link> tag references it by relative path.
- Make sure the filename is exactly style.css (lowercase, no extra characters).
- Try a hard refresh with Ctrl+Shift+R (Windows) or Cmd+Shift+R (macOS) to bypass the cache.
Why isn't my CSS loading in the browser?
- Now add the project cards, skills, footer, and responsive styles below the existing code in style.css:
.project-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 24px;
}
.project-card {
background: white;
border-radius: 8px;
padding: 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition: transform 0.2s ease;
}
.project-card:hover {
transform: translateY(-4px);
}
.project-card h3 {
font-size: 1.2rem;
margin-bottom: 10px;
color: #232f3e;
}
.project-card p {
color: #555;
margin-bottom: 15px;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.tag {
background: #e8f0fe;
color: #1a73e8;
padding: 4px 10px;
border-radius: 4px;
font-size: 0.8rem;
font-weight: 500;
}
.skills-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 24px;
}
.skill-category {
background: white;
border-radius: 8px;
padding: 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.skill-category h3 {
margin-bottom: 12px;
color: #232f3e;
}
.skill-category ul {
list-style: none;
padding: 0;
}
.skill-category li {
padding: 6px 0;
border-bottom: 1px solid #eee;
color: #555;
}
.skill-category li:last-child {
border-bottom: none;
}
footer {
text-align: center;
padding: 40px 20px;
background: #232f3e;
color: white;
font-size: 0.9rem;
}
footer p {
margin-bottom: 5px;
opacity: 0.8;
}
@media (max-width: 768px) {
.hero-content h1 {
font-size: 1.8rem;
}
.subtitle {
font-size: 1rem;
}
.project-grid,
.skills-grid {
grid-template-columns: 1fr;
}
}
What do these styles do?
- The .project-grid uses CSS Grid with auto-fit so cards automatically wrap into rows based on available screen width.
- The transition: transform 0.2s ease on .project-card creates a subtle lift effect when you hover over a card.
- The @media query at the bottom switches to a single-column layout on screens narrower than 768px, keeping the site mobile-friendly.
- Save style.css and refresh your browser.
- Your project cards should now display in a responsive grid with white backgrounds, soft shadows, and blue tags. Hover over a card to see it lift slightly.
Cards not showing in a grid?
- Make sure you pasted the new styles BELOW the existing code in style.css, not replacing it.
- Check for missing closing braces } in the first chunk of CSS. A missing brace breaks all styles that follow.
Why aren't my CSS Grid project cards displaying correctly?
✔️ Awesome, I've got everything!
Great. Make sure you have saved style.css with both chunks of CSS included.
ⓧ I'd like to double check the full code
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, sans-serif;
line-height: 1.6;
color: #333;
background-color: #f8f9fa;
}
.hero {
background: linear-gradient(135deg, #232f3e 0%, #1a73e8 100%);
color: white;
padding: 80px 20px;
text-align: center;
}
.hero-content h1 {
font-size: 2.5rem;
margin-bottom: 10px;
}
.subtitle {
font-size: 1.2rem;
opacity: 0.9;
margin-bottom: 15px;
}
.visitor-count {
font-size: 0.9rem;
opacity: 0.8;
}
main {
max-width: 1100px;
margin: 0 auto;
padding: 40px 20px;
}
section {
margin-bottom: 60px;
}
h2 {
font-size: 1.8rem;
margin-bottom: 30px;
color: #232f3e;
}
.project-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 24px;
}
.project-card {
background: white;
border-radius: 8px;
padding: 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transition: transform 0.2s ease;
}
.project-card:hover {
transform: translateY(-4px);
}
.project-card h3 {
font-size: 1.2rem;
margin-bottom: 10px;
color: #232f3e;
}
.project-card p {
color: #555;
margin-bottom: 15px;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.tag {
background: #e8f0fe;
color: #1a73e8;
padding: 4px 10px;
border-radius: 4px;
font-size: 0.8rem;
font-weight: 500;
}
.skills-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 24px;
}
.skill-category {
background: white;
border-radius: 8px;
padding: 24px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.skill-category h3 {
margin-bottom: 12px;
color: #232f3e;
}
.skill-category ul {
list-style: none;
padding: 0;
}
.skill-category li {
padding: 6px 0;
border-bottom: 1px solid #eee;
color: #555;
}
.skill-category li:last-child {
border-bottom: none;
}
footer {
text-align: center;
padding: 40px 20px;
background: #232f3e;
color: white;
font-size: 0.9rem;
}
footer p {
margin-bottom: 5px;
opacity: 0.8;
}
@media (max-width: 768px) {
.hero-content h1 {
font-size: 1.8rem;
}
.subtitle {
font-size: 1rem;
}
.project-grid,
.skills-grid {
grid-template-columns: 1fr;
}
}
Add the visitor counter JavaScript
The visitor counter needs a JavaScript function that calls your backend API. You have not built the API yet, so for now the function will attempt the call, fail gracefully, and display a fallback value.
- Open script.js in VS Code and paste the following code:
// Fetch the visitor count from the API Gateway endpoint
async function fetchVisitorCount() {
const counterElement = document.getElementById('visitor-count');
try {
// This URL will be replaced with your real API Gateway URL later
const response = await fetch('YOUR_API_GATEWAY_URL/count');
if (!response.ok) {
throw new Error('API request failed');
}
const data = await response.json();
counterElement.textContent = data.count;
} catch (error) {
console.error('Failed to fetch visitor count:', error);
// Show N/A when the API is not yet available
counterElement.textContent = 'N/A';
}
}
// Run immediately when the script loads
fetchVisitorCount();
What does this code do?
- The fetchVisitorCount() function grabs the <span id="visitor-count"> element from the page and tries to call your API.
- Since YOUR_API_GATEWAY_URL is a placeholder that does not exist yet, the fetch will fail and the catch block runs instead.
- The catch block sets the counter text to N/A. Once you build the API in a later step, you will replace the placeholder URL and the counter will display real numbers.
- Save script.js and refresh your browser.
- The hero section should now show Visitors: N/A instead of the three dots. This confirms your JavaScript is loading and running correctly.
Still seeing three dots instead of N/A?
- Open your browser's developer console (F12, then click the Console tab) and look for errors.
- Confirm script.js is in the same folder as index.html. The <script src="script.js"> tag in your HTML references it by relative path.
- Make sure the id on your span element is exactly visitor-count (with a hyphen, not underscore).
Why isn't my script.js running when I open index.html in the browser?
✔️ Awesome, I've got everything!
Great. Make sure all three files are saved.
ⓧ I'd like to double check the full code
async function fetchVisitorCount() {
const counterElement = document.getElementById('visitor-count');
try {
const response = await fetch('YOUR_API_GATEWAY_URL/count');
if (!response.ok) {
throw new Error('API request failed');
}
const data = await response.json();
counterElement.textContent = data.count;
} catch (error) {
console.error('Failed to fetch visitor count:', error);
counterElement.textContent = 'N/A';
}
}
fetchVisitorCount();
- Commit and push your website files by running these commands in your terminal:
git add .
git commit -m "Add portfolio website HTML, CSS, and JS"
git push origin main
- Check the output for a success message confirming the push to main.
Git push failed?
- If you see an authentication error, confirm your GitHub credentials are configured. You may need to set up a personal access token or SSH key.
- If Git says there is nothing to commit, make sure you saved all three files before running git add ..
Why is my git push to GitHub failing?
Your portfolio site is live locally with a polished design and a visitor counter ready to connect. Next up, you will deploy these files to S3 and serve them globally through CloudFront.
Deploy to S3 and CloudFront with Origin Access Control
Your portfolio website is built and tested locally. Now it needs a home on the internet.
In this step, you will upload your files to a private S3 bucket and then put CloudFront in front of it as a global CDN. CloudFront will serve your site over HTTPS while the S3 bucket stays completely locked down using Origin Access Control (OAC).
In this step, get ready to:
- Create a private S3 bucket and upload your website files.
- Create a CloudFront distribution with Origin Access Control.
- Verify your site loads over HTTPS via the CloudFront domain.
Create a private S3 bucket and upload your files
S3 buckets store objects (files) in the cloud. Your bucket will hold index.html, style.css, and script.js. You will keep Block Public Access enabled so nobody can access the bucket directly.
- Create your S3 bucket by running this command in your terminal:
aws s3api create-bucket \
--bucket [[S3_BUCKET_NAME="your-name-cloud-resume"]] \
--region us-east-1
Why us-east-1?
Creating the bucket in us-east-1 keeps things simple. For us-east-1 you do not need the --create-bucket-configuration flag. Other regions require it.
S3 bucket names are globally unique. Include your name or a unique identifier to avoid conflicts.
- Lock down the bucket by enabling Block Public Access:
aws s3api put-public-access-block \
--bucket [[S3_BUCKET_NAME="your-name-cloud-resume"]] \
--public-access-block-configuration "BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true"
What does this command do?
This sets all four Block Public Access flags to true. No one can make the bucket public through ACLs or bucket policies that grant public access. Only CloudFront (via OAC) will be able to read objects.
- Navigate to your cloud-resume project folder in your terminal (if not already there).
- Upload your website files to the bucket by running:
aws s3 sync . s3://[[S3_BUCKET_NAME="your-name-cloud-resume"]] \
--exclude ".git/*" \
--exclude ".github/*" \
--exclude "lambda/*" \
--exclude "README.md"
What does this command do?
The aws s3 sync command uploads all files from your current directory to the bucket. The --exclude flags skip Git metadata, the GitHub Actions workflow, the Lambda folder, and the README. Only the website files end up in the bucket.
- Verify the upload by opening the AWS Management Console.
- Navigate to the S3 service.
- Click on your bucket name.
- Confirm you see index.html, style.css, and script.js listed as objects.
Don't see your files in the bucket?
Make sure your terminal is in the cloud-resume project folder before running aws s3 sync. If you ran it from the wrong directory, the wrong files (or no files) get uploaded.
Also confirm your AWS CLI is configured with valid credentials by running aws sts get-caller-identity. If that errors, reconfigure your CLI.
help me debug my S3 upload
Create a CloudFront distribution with OAC
CloudFront is a content delivery network that caches your site at edge locations worldwide. OAC tells CloudFront to sign every request it makes to S3, so only CloudFront can read your bucket.
- Open the CloudFront service in the AWS Management Console.
- Click Create distribution.
- Under Origin type, select Amazon S3.
- In the S3 origin dropdown, select your bucket.
- Under Origin access, choose Origin access control settings (recommended).
- Click Create new OAC. Accept the defaults and click Create.
- Scroll down to Default root object and enter index.html.
- Leave all other settings as default and click Create distribution.
What is Origin Access Control?
OAC is the AWS-recommended method for restricting access to S3 origins. It replaces the older Origin Access Identity (OAI) approach. OAC uses short-term credentials and IAM service principals to sign requests, giving CloudFront permission to read from your private bucket without making the bucket public.
After creating the distribution, CloudFront displays a banner prompting you to update the S3 bucket policy. You need to copy and apply this policy so CloudFront can access your files.
- Click Copy policy in the blue banner at the top of the distribution page.
- Open the S3 service in a new tab.
- Click on your bucket name.
- Go to the Permissions tab.
- Scroll down to Bucket policy and click Edit.
- Paste the policy you copied from CloudFront.
- Click Save changes.
What does this bucket policy do?
The policy grants the cloudfront.amazonaws.com service principal permission to call s3:GetObject on your bucket. It includes an aws:SourceArn condition scoped to your specific distribution. This means only YOUR CloudFront distribution can read from the bucket.
Don't see the Copy policy banner?
If you navigated away from the distribution page, go back to CloudFront and select your distribution. Click the Origins tab, select your S3 origin, click Edit, and the policy banner reappears.
help me find the CloudFront bucket policy
Verify your site is live on CloudFront
CloudFront distributions take a few minutes to deploy across edge locations worldwide. You need to wait for the status to change before testing.
- Go back to the CloudFront console and select your distribution.
- Wait until the Last modified field shows a recent timestamp and the Status shows Enabled. This usually takes 3-5 minutes.
- Copy the Distribution domain name (it looks like d111111abcdef8.cloudfront.net).
- Paste the domain name into your browser address bar and press Enter.
You should see your portfolio website loading over HTTPS with a valid certificate. The page should look the same as when you tested locally.
- Note down your CloudFront distribution ID (visible at the top of the distribution details page). You will need it in a later step for cache invalidations.
- Verify that direct S3 access is blocked by trying to access a file directly through S3. In the S3 console, click on index.html and copy the Object URL. Paste it into your browser.
- Confirm you see an AccessDenied error. This proves your bucket is properly locked down.
Why block direct S3 access?
If S3 were publicly accessible, anyone could bypass CloudFront and access your files directly. This defeats the purpose of using a CDN for caching, HTTPS, and access control. OAC ensures all traffic goes through CloudFront, giving you a single entry point to secure and monitor.
Seeing a 403 error on your CloudFront URL?
If visiting your CloudFront domain shows an error instead of your site, the bucket policy likely was not applied correctly. Go back to your S3 bucket, open the Permissions tab, and confirm the bucket policy is present.
Also make sure you entered index.html as the Default root object when creating the distribution. Without it, CloudFront does not know which file to serve at the root URL.
help me fix my CloudFront 403 error
Your portfolio is now live on the internet, served globally through CloudFront edge locations with HTTPS encryption. Next up, you will build the serverless backend that powers the visitor counter.
Build the Serverless Visitor Counter Backend
Your portfolio site is live and accessible globally through CloudFront. Now it is time to make it dynamic.
Every time someone visits your site, you want a counter to increment. You will build this using three AWS services working together: DynamoDB stores the count, Lambda runs the code that updates it, and IAM controls what Lambda is allowed to do.
In this step, get ready to:
- Create a DynamoDB table to store visitor counts.
- Write a Lambda function that increments the count on each visit.
- Configure IAM permissions and test the function.
Create the DynamoDB table
DynamoDB is a fully managed NoSQL database. You will create a table with a single item that holds your visitor count.
- Open the AWS Management Console and navigate to the DynamoDB service.
- In the left navigation pane, click Tables.
- Click Create table.
- For Table name, enter cloud-resume-visitors.
- For Partition key, enter id and leave the type as String.
- Under Table settings, select Customize settings.
- For Read/write capacity settings, keep Provisioned selected.
- Turn off Auto scaling for both Read capacity and Write capacity.
- Set Read capacity units to 1 and Write capacity units to 1.
- Click Create table.
Why provisioned capacity at 1/1?
The DynamoDB Always Free tier includes 25 RCUs and 25 WCUs perpetually. Setting your table to 1 RCU and 1 WCU keeps it well within that free allowance. A personal portfolio visitor counter will never need more than this.
Wait for the table status to change to Active. Now add the initial visitor count item.
- Click on your cloud-resume-visitors table name to open it.
- Click the Explore table items button.
- Click Create item.
- For the id attribute value, enter visitor_count.
- Click Add new attribute and select Number.
- For the attribute name, enter views. For the value, enter 0.
- Click Create item.
You should see your new item in the table with id = visitor_count and views = 0.
Don't see your item?
Make sure you clicked Explore table items and not the Overview tab. If the item still does not appear, click Run to scan the table again.
help me troubleshoot why my DynamoDB item is not showing up in the console
Create the Lambda function and write the handler
Lambda lets you run code without provisioning servers. Your function will connect to DynamoDB, increment the visitor count, and return the new value with CORS headers so your frontend can call it.
- In the AWS console, navigate to the Lambda service.
- Click Create function.
- Select Author from scratch.
- For Function name, enter CloudResumeCounter.
- For Runtime, select Python 3.12.
- Leave Architecture set to x86_64.
- Click Create function.
Why Python 3.12?
The Python 3.12 Lambda runtime comes with boto3 (the AWS SDK for Python) pre-installed. This means your function has zero external dependencies to package. You can write and deploy the code directly in the console editor.
Lambda creates a default file called lambda_function.py with placeholder code. You will replace it with your visitor counter logic.
- In the Code source pane, click the lambda_function.py tab to open the editor.
- Select all the existing code and delete it.
- Paste the following imports and DynamoDB connection setup:
import json
import boto3
# Connect to DynamoDB and reference the visitor counter table
dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('cloud-resume-visitors')
What does this code do?
boto3.resource('dynamodb') creates a high-level connection to the DynamoDB service. table is a reference to your specific table. These lines run once when Lambda initializes the function, not on every invocation.
- Below the table connection code, paste the handler function:
def lambda_handler(event, context):
try:
# Atomically increment the visitor count by 1
response = table.update_item(
Key={'id': 'visitor_count'},
UpdateExpression='ADD #v :inc',
ExpressionAttributeNames={'#v': 'views'},
ExpressionAttributeValues={':inc': 1},
ReturnValues='UPDATED_NEW'
)
# Extract the new count from the response
count = int(response['Attributes']['views'])
# Return success with CORS headers
return {
'statusCode': 200,
'headers': {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type'
},
'body': json.dumps({'count': count})
}
except Exception as e:
# Log the error and return a 500 response
print(f'Error: {str(e)}')
return {
'statusCode': 500,
'headers': {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
},
'body': json.dumps({'error': 'Internal server error'})
}
What does this code do?
update_item with the ADD expression atomically increments the views attribute by 1. This is safe even if multiple visitors hit the counter simultaneously.
The CORS headers (Access-Control-Allow-Origin) allow your CloudFront-hosted frontend to call this function. Without them, the browser would block the request.
- Click Deploy to save your function code.
You should see a green banner confirming the function was updated successfully.
Seeing a syntax error on deploy?
Make sure you deleted all the original placeholder code before pasting. Check that the import json line is at the very top of the file with no leading whitespace.
help me fix a syntax error in my Lambda function code
✔️ Awesome, I've got everything!
Great. Make sure you clicked Deploy and saw the success confirmation.
ⓧ I'd like to double check the full code
Here is the complete lambda_function.py file:
import json
import boto3
dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('cloud-resume-visitors')
def lambda_handler(event, context):
try:
response = table.update_item(
Key={'id': 'visitor_count'},
UpdateExpression='ADD #v :inc',
ExpressionAttributeNames={'#v': 'views'},
ExpressionAttributeValues={':inc': 1},
ReturnValues='UPDATED_NEW'
)
count = int(response['Attributes']['views'])
return {
'statusCode': 200,
'headers': {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*',
'Access-Control-Allow-Methods': 'GET, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type'
},
'body': json.dumps({'count': count})
}
except Exception as e:
print(f'Error: {str(e)}')
return {
'statusCode': 500,
'headers': {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
},
'body': json.dumps({'error': 'Internal server error'})
}
Configure IAM permissions and test the function
Your Lambda function needs permission to read and write to your DynamoDB table. By default, Lambda only has permission to write logs. You will create a custom IAM policy scoped to your specific table.
- In the Lambda console, click the Configuration tab for your CloudResumeCounter function.
- Click Permissions in the left sidebar.
- Under Execution role, click the role name link. This opens the IAM console in a new tab.
- Click Add permissions, then Create inline policy.
- Switch to the JSON tab and paste the following policy:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"dynamodb:GetItem",
"dynamodb:UpdateItem"
],
"Resource": "arn:aws:dynamodb:[[AWS_REGION="us-east-1"]]:[[AWS_ACCOUNT_ID="123456789012"]]:table/cloud-resume-visitors"
}
]
}
What does this policy do?
This grants the Lambda function only two specific DynamoDB actions: dynamodb:GetItem and dynamodb:UpdateItem. The Resource field scopes the permission to your exact table ARN. This follows the principle of least privilege.
You can find your AWS account ID in the top-right corner of the console. Your region is shown in the console URL bar.
- Replace the region and account ID placeholders with your actual values.
- Click Next.
- For Policy name, enter DynamoDBVisitorCounterAccess.
- Click Create policy.
Now test the function to confirm everything is connected.
- Go back to the Lambda console tab with your CloudResumeCounter function.
- Click the Test tab.
- For Event name, enter testVisitor.
- Leave the event JSON as the default empty object {}.
- Click Test.
Under Execution result, expand Details. You should see a response with statusCode 200 and a body containing {"count": 1}.
Seeing an AccessDeniedException?
This means the IAM policy is not attached correctly. Go back to IAM, open the Lambda execution role, and verify that DynamoDBVisitorCounterAccess appears under the role's policies. Also confirm the table ARN in the policy matches your region and account ID exactly.
help me fix an AccessDeniedException in my Lambda function when accessing DynamoDB
- Click Test again. The count should now increment to 2.
Your serverless backend is working. Next up, you will expose this function through an API Gateway so your website can call it from the browser.
Create the API Gateway and Connect the Frontend
Your Lambda function is tested and working, but it has no way to receive requests from the outside world. You need a public HTTP endpoint that your portfolio site can call from the browser.
API Gateway HTTP API acts as the front door for your Lambda function. It receives HTTP requests, routes them to your function, and returns the response to the caller.
In this step, get ready to:
- Create an API Gateway HTTP API with a GET route connected to your Lambda function.
- Update your frontend JavaScript to call the live API endpoint.
- Test the full visitor counter end-to-end.
Create the HTTP API and add a Lambda integration
API Gateway HTTP APIs are lightweight, low-latency APIs designed for serverless workloads. You will create one with a GET route that triggers your CloudResumeCounter function.
- Open the API Gateway console at https://console.aws.amazon.com/apigateway.
- Click Create API.
- Under HTTP API, click Build.
- Click Add integration and select Lambda from the dropdown.
- In the Lambda function field, select your CloudResumeCounter function.
- For API name, enter cloud-resume-api.
- Click Next.
The next screen lets you configure routes.
- Change the method to GET and the resource path to /count.
- Click Next.
- Review the default stage ($default with auto-deploy enabled) and click Next.
- Click Create.
What does this create?
A single GET /count route is now wired to your Lambda function through a Lambda proxy integration. When a browser sends a GET request to /count, API Gateway forwards the request to CloudResumeCounter and returns whatever your function responds with.
The $default stage with auto-deploy means every change you make to this API is immediately live. No manual deployments required.
Now configure CORS so your CloudFront-hosted site can call this API from the browser.
- In the left sidebar of your API, click CORS.
- Click Configure.
- Under Access-Control-Allow-Origin, enter * and click Add.
- Under Access-Control-Allow-Methods, select GET and click Add.
- Under Access-Control-Allow-Headers, enter content-type and click Add.
- Click Save.
Why allow all origins with * ?
Using * allows any domain to call your API. This is acceptable during development and for a personal portfolio. For production APIs serving sensitive data, you would restrict this to your specific CloudFront domain.
Now copy your API's invoke URL for use in the next substep.
- In the left sidebar, click your API name to go back to the Details page.
- Find the Invoke URL value and copy it. It looks like https://abcdef123.execute-api.us-east-1.amazonaws.com.
Save this URL somewhere handy
You will paste this invoke URL into your JavaScript file in the next substep. Keep it in your clipboard or a temporary note.
Don't see an Invoke URL?
Make sure you completed the creation wizard by clicking Create on the review page. The invoke URL appears on the API's main details page after creation.
Help me find my invoke URL
Update script.js to call the live API
Your frontend JavaScript currently has a placeholder URL. You will replace it with your real API Gateway invoke URL so the visitor counter fetches live data.
- In VS Code, open script.js.
- Find this line:
const response = await fetch('YOUR_API_GATEWAY_URL/count');
- Replace YOUR_API_GATEWAY_URL with your actual invoke URL (keep /count at the end), so the line looks like this:
const response = await fetch('https://abcdef123.execute-api.us-east-1.amazonaws.com/count');
What does this change do?
The fetch() call sends a GET request to your API Gateway endpoint. API Gateway routes it to your Lambda function, which increments the counter in DynamoDB and returns the new count as JSON. The function then updates the visitor-count element on the page with the response.
- Save script.js.
- Upload the updated file to your S3 bucket by running this command:
aws s3 cp script.js s3://[[BUCKET_NAME="your-bucket-name"]]/script.js
- Create a CloudFront invalidation so the edge caches serve your updated file immediately:
aws cloudfront create-invalidation --distribution-id [[DISTRIBUTION_ID="your-distribution-id"]] --paths "/script.js"
Why invalidate the cache?
CloudFront caches your files at edge locations for up to 24 hours by default. Without an invalidation, visitors would keep seeing the old script.js with the placeholder URL. Invalidating /script.js forces CloudFront to fetch the fresh version from S3 on the next request.
You should see output confirming the invalidation was created with a status of InProgress.
Seeing 'access denied' on the S3 upload?
Make sure your AWS CLI credentials are configured and have s3:PutObject permission on the bucket. Run aws sts get-caller-identity to confirm you are authenticated as the expected user.
Help me fix my S3 upload permissions
Test the full visitor counter end-to-end
Every piece of the architecture is now connected. A page load triggers the JavaScript, which calls API Gateway, which invokes Lambda, which updates DynamoDB and returns the new count.
- Open your CloudFront URL in a browser (e.g. https://d111111abcdef8.cloudfront.net).
- Look at the visitor count number in the hero section. It should display a number (e.g. 1).
- Refresh the page. The counter should increment by 1 on each refresh.
Now verify the Lambda execution in CloudWatch.
- Open the CloudWatch console at https://console.aws.amazon.com/cloudwatch.
- In the left sidebar, click Log groups.
- Find and click the log group named /aws/lambda/CloudResumeCounter.
- Open the most recent log stream. You should see log entries showing successful invocations with a 200 status code.
What should I see in CloudWatch?
Each log stream contains entries for START, END, and REPORT lines for every Lambda invocation. Seeing these confirms that API Gateway is successfully calling your function. If your function hit an error, you would see the error message in the logs between the START and END lines.
Finally, commit and push your updated script.js to GitHub.
- Stage, commit, and push your changes by running these commands:
git add script.js
git commit -m "Connect visitor counter to live API Gateway endpoint"
git push origin main
Counter shows N/A instead of a number?
Open your browser's developer tools (F12 or Cmd+Option+I on macOS) and check the Console tab for error messages. Common causes:
- A CORS error means your API Gateway CORS configuration is missing or incorrect. Go back to the CORS settings and verify you added * to allowed origins.
- A 403 or 404 error means your invoke URL or route path is wrong. Double check that your URL ends with /count and the route in API Gateway is GET /count.
- A 500 error means the Lambda function failed. Check CloudWatch logs for the specific error message.
Help me debug my visitor counter
Your serverless backend is live and connected to your portfolio. Next up, you will automate deployments so that every code push updates your site automatically.
Automate Deployments with GitHub Actions and OIDC
Your portfolio site is live, your visitor counter works, and every piece connects through AWS services you configured by hand. The problem is that every future update requires manually uploading files to S3 and invalidating the CloudFront cache.
In this step, you will set up a CI/CD pipeline using GitHub Actions with OIDC federation. Every push to your main branch will automatically sync files to S3 and clear the CloudFront cache, with no long-lived AWS credentials stored anywhere.
In this step, get ready to:
- Create an IAM Identity Provider and Role for GitHub OIDC authentication.
- Write a GitHub Actions workflow that deploys your site on every push.
- Verify the pipeline runs successfully and delivers changes to your live site.
Set up OIDC federation in AWS
Traditional CI/CD pipelines store long-lived AWS access keys as repository secrets. OIDC federation eliminates that risk entirely by letting GitHub generate a short-lived token for each workflow run.
Why OIDC instead of stored access keys?
With OIDC, GitHub sends a signed JWT to AWS on each workflow run. AWS exchanges it for temporary credentials that expire in minutes. If your repository is compromised, an attacker cannot extract permanent AWS credentials because none exist.
First, you will create an IAM Identity Provider that tells AWS to trust tokens from GitHub.
- In the AWS Management Console, navigate to the IAM service.
- In the left sidebar, click Identity providers.
- Click Add provider.
- Select OpenID Connect as the provider type.
- For Provider URL, enter https://token.actions.githubusercontent.com.
- For Audience, enter sts.amazonaws.com.
- Click Add provider.
Next, create an IAM Role that GitHub Actions will assume. This role defines exactly what the workflow is allowed to do in your AWS account.
- In the IAM left sidebar, click Roles.
- Click Create role.
- Select Custom trust policy as the trusted entity type.
- Replace the default policy JSON with the following trust policy:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::YOUR_ACCOUNT_ID:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
},
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:YOUR_GITHUB_USERNAME/cloud-resume:ref:refs/heads/main"
}
}
}
]
}
What does this trust policy do?
- The Federated principal tells AWS to accept tokens from the GitHub OIDC provider in your account.
- The aud condition confirms the token is intended for AWS.
- The sub condition scopes access to only the main branch of your specific repository. No other repo or branch can assume this role.
- Replace YOUR_ACCOUNT_ID with your 12-digit AWS account ID.
- Replace YOUR_GITHUB_USERNAME with your actual GitHub username.
- Click Next.
- On the permissions page, click Next without attaching any managed policies (you will create a custom one).
- For Role name, enter GitHubActionsDeployRole.
- Click Create role.
Now attach a permissions policy that grants only the actions the workflow needs.
- Click into the GitHubActionsDeployRole role you just created.
- Under the Permissions tab, click Add permissions, then Create inline policy.
- Switch to the JSON editor and paste the following policy:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:PutObject",
"s3:DeleteObject",
"s3:ListBucket"
],
"Resource": [
"arn:aws:s3:::YOUR_BUCKET_NAME",
"arn:aws:s3:::YOUR_BUCKET_NAME/*"
]
},
{
"Effect": "Allow",
"Action": "cloudfront:CreateInvalidation",
"Resource": "arn:aws:cloudfront::YOUR_ACCOUNT_ID:distribution/YOUR_DISTRIBUTION_ID"
}
]
}
What does this permissions policy do?
- The first statement allows the workflow to upload, delete, and list objects in your S3 bucket.
- The second statement allows the workflow to create CloudFront cache invalidations for your specific distribution.
- Both are scoped to exact resource ARNs, following IAM least-privilege principles.
- Replace YOUR_BUCKET_NAME with your S3 bucket name.
- Replace YOUR_ACCOUNT_ID with your 12-digit AWS account ID.
- Replace YOUR_DISTRIBUTION_ID with your CloudFront distribution ID (found on the CloudFront console under your distribution's General tab).
- Click Next.
- Enter a policy name such as GitHubActionsDeployPolicy.
- Click Create policy.
You should now see the inline policy listed under your role's Permissions tab.
Don't see the identity provider or role?
Make sure you are in the correct AWS region. IAM resources are global, but the console sometimes defaults to a different region view. Double-check that your account ID in the trust policy matches exactly (no extra spaces or dashes).
help me troubleshoot my IAM OIDC provider or role setup for GitHub Actions
Create the GitHub Actions workflow file
The workflow file tells GitHub exactly what to do when you push code. It lives in a special directory that GitHub monitors for automation definitions.
- In VS Code, open the .github/workflows/ directory that you created earlier.
- Create a new file named deploy.yml inside this directory.
- Paste the following workflow configuration into deploy.yml:
name: Deploy to S3 and Invalidate CloudFront
on:
push:
branches:
- main
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::YOUR_ACCOUNT_ID:role/GitHubActionsDeployRole
aws-region: us-east-1
- name: Sync files to S3
run: |
aws s3 sync . s3://YOUR_BUCKET_NAME \
--delete \
--exclude ".git/*" \
--exclude ".github/*" \
--exclude "lambda/*" \
--exclude "README.md"
- name: Invalidate CloudFront cache
run: |
aws cloudfront create-invalidation \
--distribution-id YOUR_DISTRIBUTION_ID \
--paths "/*"
What does this workflow do?
- The on: push trigger runs the workflow every time you push to main.
- The permissions: id-token: write line allows GitHub to request an OIDC token for the workflow run.
- The aws-actions/configure-aws-credentials@v4 action exchanges the GitHub OIDC token for temporary AWS credentials using your GitHubActionsDeployRole.
- The aws s3 sync command uploads your website files while excluding non-website directories.
- The --delete flag removes files from S3 that no longer exist in your repository.
- The final step invalidates the CloudFront cache so visitors see your latest changes immediately.
- Replace YOUR_ACCOUNT_ID with your 12-digit AWS account ID.
- Replace YOUR_BUCKET_NAME with your S3 bucket name.
- Replace YOUR_DISTRIBUTION_ID with your CloudFront distribution ID.
- Save deploy.yml.
✔️ Awesome, I've got everything!
Great. Double check you have saved deploy.yml in the correct location: .github/workflows/deploy.yml.
ⓧ I'd like to double check the full code
Here is the complete deploy.yml file for comparison:
name: Deploy to S3 and Invalidate CloudFront
on:
push:
branches:
- main
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::YOUR_ACCOUNT_ID:role/GitHubActionsDeployRole
aws-region: us-east-1
- name: Sync files to S3
run: |
aws s3 sync . s3://YOUR_BUCKET_NAME \
--delete \
--exclude ".git/*" \
--exclude ".github/*" \
--exclude "lambda/*" \
--exclude "README.md"
- name: Invalidate CloudFront cache
run: |
aws cloudfront create-invalidation \
--distribution-id YOUR_DISTRIBUTION_ID \
--paths "/*"
Commit, push, and verify the pipeline
The workflow triggers automatically when you push to main. Committing and pushing the workflow file itself counts as a push, so the pipeline will run immediately.
- Commit and push the workflow file by running these commands in your terminal:
git add .github/workflows/deploy.yml
git commit -m "Add GitHub Actions deployment workflow"
git push origin main
- Open your cloud-resume repository on GitHub in your browser.
- Click the Actions tab at the top of the repository.
- You should see a workflow run in progress or completed with a green checkmark.
What should I see?
The Actions tab shows each workflow run. A green checkmark means all steps passed. Click into the run to see logs for each step (Checkout, Configure AWS credentials, Sync files, Invalidate cache).
Now test that future pushes automatically deploy to your live site.
- In VS Code, open index.html.
- Make a small visible change (for example, edit the subtitle text or add a word to the hero heading).
- Save the file.
- Commit and push the change by running:
git add index.html
git commit -m "Test automated deployment"
git push origin main
- Go back to the Actions tab on GitHub and confirm a new workflow run triggers.
- Once the run completes with a green checkmark, visit your CloudFront URL in the browser.
- Verify that your change is now live on the site.
Your change should appear within a minute or two, since the workflow invalidates the CloudFront cache after syncing.
Workflow failed with an access denied error?
Check that the role-to-assume ARN in your deploy.yml matches the role name GitHubActionsDeployRole exactly (case-sensitive). Also confirm your trust policy's sub condition uses your actual GitHub username and repository name.
If you see an S3 access error, verify the inline policy's bucket name matches your actual bucket (no typos, no trailing slashes).
help me debug my GitHub Actions OIDC workflow that is failing with access denied
Your entire deployment is now automated. Every push to main syncs your code to S3 and invalidates the CloudFront cache, all authenticated through short-lived OIDC tokens. Up next in the Secret Mission, you can add a custom domain with Route 53 and an SSL certificate.
Secret mission
Add a Custom Domain with Route 53 and ACM
Your portfolio is live, but it's hiding behind a generic CloudFront URL. In this stretch challenge, you'll register a custom domain, provision an SSL certificate, and wire up DNS so visitors reach your site at a clean, professional address like yourname.dev.
Clean Up Your Resources
Clean Up Your Resources
The core project runs entirely within AWS Free Tier and Always Free limits, so there are no ongoing costs. If you completed the Secret Mission, your Route 53 hosted zone costs $0.50/month.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Cost warning
If you completed the Secret Mission, your Route 53 hosted zone costs $0.50 per month. Your domain registration also has an annual renewal fee. If you do not plan to use the custom domain, delete the hosted zone and consider not renewing the domain.
Resources you used:
- S3 bucket storing your website files (within Free Tier storage limits).
- CloudFront distribution serving your site globally (Always Free: 1 TB transfer out, 10M requests/month).
- DynamoDB table cloud-resume-visitors (Always Free: 25 RCU, 25 WCU, 25 GB perpetually).
- Lambda function CloudResumeCounter (Always Free: 1M requests/month).
- API Gateway HTTP API cloud-resume-api (1M requests/month free for 12 months).
- IAM roles, policies, and OIDC identity provider (no cost).
- GitHub repository cloud-resume and local project folder (no cost).
- Route 53 hosted zone (Secret Mission only, $0.50/month).
- ACM certificate (Secret Mission only, no additional charge with CloudFront).
Keep everything running
No action needed. Choose this if you are still actively building or want to keep your portfolio live to share with employers.
- The entire stack costs $0/month within AWS Free Tier and Always Free limits.
- If you completed the Secret Mission, Route 53 costs $0.50/month for the hosted zone.
- Your site remains live and accessible at your CloudFront domain (or custom domain if configured).
- The GitHub Actions pipeline continues to deploy changes on every push to main.
Pause - I'll come back to this later
Serverless resources only cost money when used. There is nothing to pause for the core project because you are not charged when there is no traffic.
- Your S3 bucket, Lambda function, DynamoDB table, and API Gateway sit idle at no cost.
- CloudFront serves requests within Always Free limits (1 TB transfer, 10M requests/month).
- If you completed the Secret Mission, the Route 53 hosted zone continues to cost $0.50/month even with zero traffic. Delete the hosted zone if you want to eliminate this cost while you are away.
Delete - I don't want to use this again
Remove all project resources and start fresh. Delete in the order listed below to avoid dependency errors.
1. Disable and delete the CloudFront distribution:
- Open the CloudFront console and select your distribution.
- Click Disable.
- Wait for the Status to change from Deploying to Disabled. This can take several minutes.
- Once disabled, select the distribution and click Delete.
2. Empty and delete the S3 bucket:
- Empty your bucket by running this command:
aws s3 rm s3://[[BUCKET_NAME="your-bucket-name"]] --recursive
- Then delete the empty bucket by running:
aws s3 rb s3://[[BUCKET_NAME="your-bucket-name"]]
3. Delete the DynamoDB table:
- Open the DynamoDB console and select Tables.
- Select the cloud-resume-visitors table.
- Click Delete and confirm.
4. Delete the Lambda function:
- Open the Lambda console and select Functions.
- Select CloudResumeCounter.
- Click Actions, then Delete.
5. Delete the API Gateway HTTP API:
- Open the API Gateway console.
- Select cloud-resume-api.
- Click Delete and confirm.
6. Delete IAM roles, policies, and the OIDC identity provider:
- Open the IAM console and select Roles.
- Find and delete the GitHubActionsDeployRole and the Lambda execution role.
- Select Policies and delete any custom policies you created for this project.
- Select Identity providers and delete the GitHub OIDC provider (token.actions.githubusercontent.com).
7. Delete the GitHub repository and local project folder:
- On GitHub, go to your cloud-resume repository.
- Click Settings, scroll to the bottom, and click Delete this repository.
- Delete your local project folder by running:
rm -rf ~/Desktop/cloud-resume
On Windows, run this instead:
Remove-Item -Recurse -Force ~\Desktop\cloud-resume
8. Secret Mission resources (if completed):
- Open the Route 53 console and select Hosted zones.
- Select your hosted zone and delete all records except the NS and SOA records (which are deleted automatically with the zone).
- Click Delete hosted zone and confirm.
- Open the ACM console in us-east-1.
- Select your certificate and click Delete.
Nice Work!
Nice Work!
You just built a production-grade serverless portfolio on AWS. That is not a toy project.
You now have a live, globally-distributed website backed by a serverless API and automated deployments. Every piece of this architecture reflects how real cloud engineers build in production.
You've learned how to:
- Build a globally-distributed portfolio website with CloudFront edge caching and Origin Access Control security.
- Create a serverless visitor counter using the API Gateway + Lambda + DynamoDB pattern.
- Implement a CI/CD pipeline with GitHub Actions using OIDC federation with no stored credentials.
- Added a custom domain with Route 53 DNS and ACM SSL certificate (Secret Mission).
Ready to quiz yourself?