Build a Student Group Website on S3
Build a student org website with an admin dashboard, auth, and custom domain.
Introduction
30 Second Summary
Every student organization needs a home on the internet. But a real org website needs more than static pages. You need member registration, officer management, event tracking, and an admin dashboard that non-technical successors can use without touching code.
In this project, you will build a full-stack student org website using Astro and Tailwind CSS for the frontend, DynamoDB for the database, Lambda for the API, Cognito for admin authentication, and SES for sending membership emails. You will deploy it on S3 with CloudFront and connect your own custom domain.
What You'll Build
A complete student organization platform with a public-facing website, a login-protected admin dashboard for managing members, officers, and events, and a custom domain with SSL.
By the end of this project, you'll have:
- A live website on your custom domain with scroll animations, responsive hamburger nav, and pages for Home, Events, and Officers.
- A serverless backend with 3 DynamoDB tables, Lambda CRUD APIs, Cognito authentication, and SES email integration.
- A Cognito-secured admin dashboard where non-technical admins manage members, promote officers, and create events without writing code.
- Secret Mission: Build an email blast feature to announce events to all members via their personal email.
Are there any prerequisites?
You need an AWS account, a purchased domain on name.com (this project uses awssbg-cdm.live), and basic familiarity with HTML, CSS, and JavaScript. No framework or backend experience required.
Before We Start
You are building a public website for your AWS Student Builder Group so members and prospective members can learn about the org, see upcoming events, and know who the officers are.
You are hosting it on AWS S3 because your org is an AWS community, and this gives you real cloud experience relevant to the AWS Cloud Practitioner exam. In this step, you will set up your development tools and scaffold the project.
In this step, get ready to:
- Verify or install Node.js and npm.
- Install and configure the AWS CLI.
- Create an Astro project with Tailwind CSS 4.
Verify or install Node.js
Astro 7 requires Node.js version v22.12.0 or higher to run. You need to confirm that Node.js is installed on your machine and that the version meets this requirement.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type VS Code and press Enter to open it.
- Open the integrated terminal by pressing Ctrl+` on your keyboard.
- Check your Node.js version by running this command:
node --version
What does this command do?
This prints the version of Node.js installed on your machine. You need to see v22.12.0 or higher in the output.
✔️ I see version 22.12.0 or higher
Your Node.js version meets the requirement. Move on to verifying npm below.
ⓧ I see an older version
Your installed Node.js is too old for Astro 7. Download the current LTS installer (v24.18.0) from the official download page:
- Visit https://nodejs.org/en/download in your browser.
- Download the LTS installer for your operating system.
- Run the installer and follow the prompts.
- Close and reopen your VS Code terminal, then run node --version again to confirm.
ⓧ Command not found
Node.js is not installed yet. Download the current LTS installer (v24.18.0):
- Visit https://nodejs.org/en/download in your browser.
- Download the LTS installer for your operating system.
- Run the installer and follow the prompts.
- Close and reopen your VS Code terminal, then run node --version again to confirm you see v22.12.0 or higher.
- Verify that npm is available by running:
npm --version
You should see a version number printed. npm is bundled with Node.js, so if Node.js installed correctly, npm is ready to go.
npm command not found?
If you just installed Node.js, make sure you closed and reopened your terminal. The terminal needs to reload its path to find newly installed commands.
Still stuck? Why is npm not found even though I installed Node.js?
Install AWS CLI v2
The AWS CLI lets you interact with AWS services from your terminal. You will use it later to upload your website files to S3.
- Check if the AWS CLI is already installed by running:
aws --version
✔️ I see aws-cli/2.x.x
The AWS CLI v2 is installed. Skip ahead to configuring your credentials below.
ⓧ Command not found or I see version 1.x
You need to install AWS CLI v2. Follow these steps:
- Download the AWS CLI v2 installer for your platform:
- Windows: Download the MSI from https://awscli.amazonaws.com/AWSCLIV2.msi and run it.
- macOS: Visit the AWS CLI install guide and follow the macOS instructions.
- Follow the installer prompts to completion.
- Close and reopen your VS Code terminal.
- Run aws --version again to confirm you see aws-cli/2.x.x in the output.
- Configure your AWS credentials by running:
aws configure
What does aws configure ask for?
This command walks you through four prompts. Enter each value when prompted:
- AWS Access Key ID - paste the access key from your AWS account.
- AWS Secret Access Key - paste the secret key from your AWS account.
- Default region name - enter ap-southeast-1 (closest to the Philippines).
- Default output format - enter json.
🙋♀️ Not sure where to find your AWS keys?
In the AWS Console, click your username in the top-right corner, then select Security credentials. Under Access keys, create a new access key if you do not have one already.
Need more help? How do I find my AWS Access Key ID and Secret Access Key?
Create the Astro project with Tailwind CSS
Astro is a static site generator that outputs pure HTML with zero JavaScript by default. It is a perfect fit for hosting on S3 because the output is just static files.
- Move to your Desktop by running this command:
cd ~/Desktop
- Start the Astro project creation wizard by running:
npm create astro@latest
What will the wizard ask?
The wizard walks you through several prompts. Answer each one as follows:
- If asked to install create-astro, enter y.
- Project name: enter asbg-website.
- Template: use the arrow keys to select the Empty (minimal) template, then press Enter.
- Install dependencies: enter y.
- Initialize a git repository: enter y.
- Once the wizard completes, move into the new project folder by running:
cd asbg-website
- Add Tailwind CSS 4 to your project by running:
npx astro add tailwind
What does this command do?
The astro add command runs an automatic integration wizard. It installs the @tailwindcss/vite plugin, updates your astro.config.mjs to include it, and creates src/styles/global.css with the Tailwind import.
When prompted to confirm the changes, enter y to accept.
- Start the development server by running:
npm run dev
- Open http://localhost:4321 in your browser.
You should see the default Astro page. This confirms your project is scaffolded correctly and the dev server is running.
Page not loading at localhost:4321?
Make sure the terminal still shows the dev server running (you should see a message with the local URL). If the server stopped, run npm run dev again from inside the asbg-website folder.
If port 4321 is already in use, Astro may choose a different port. Check the terminal output for the actual URL.
Still stuck? Help me get my Astro dev server running.
✔️ Awesome, I've got everything!
Great. Make sure you have saved all files and your dev server is running at http://localhost:4321.
ⓧ I'd like to double check the full code
After running npx astro add tailwind, your astro.config.mjs and src/styles/global.css should look like this:
import { defineConfig } from "astro/config";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
vite: {
plugins: [tailwindcss()],
},
});
@import "tailwindcss";
Your development environment is ready and your Astro project is live locally. Next up, you will build the shared layout and create the Home page for your organization website.
Build the Layout and Home Page
Build the Layout with Logo and Nav
Your development environment is ready. Now it is time to give your website its own identity and a professional navigation experience.
Every page on your site shares the same navigation bar and footer. You will create a layout component with your org's custom SVG logo, a responsive hamburger menu that collapses on mobile, and the AOS animation library so elements fade in smoothly as visitors scroll.
In this step, get ready to:
- Add your custom SVG logo as the site favicon and nav branding.
- Create a shared layout component with a responsive hamburger navigation menu.
- Set up AOS (Animate on Scroll) for scroll-triggered animations across the site.
Add your custom logo
Your org logo replaces the default Astro favicon (the icon shown in the browser tab) and appears in the navigation bar. You will place the SVG file in the public/ folder so Astro serves it as a static asset.
- In the VS Code Explorer sidebar, open the public folder.
- Delete the existing favicon.svg file (right-click, Delete).
- Drag your org's SVG logo file into the public folder.
- Rename it to favicon.svg so the layout can reference it consistently.
Install the AOS animation library
- Open a new terminal in VS Code by pressing Ctrl+` (backtick key, top-left of keyboard).
- Install AOS as a project dependency by running this command:
npm install aos
Why install instead of using a CDN?
Astro processes <script> tags as ES modules and bundles them with Vite. A CDN-loaded global like AOS is not available inside Astro's module scope, which causes the "AOS is not defined" error. Installing via npm lets you import AOS from 'aos' directly in your script.
---
// Import the global Tailwind stylesheet
import "../styles/global.css";
// Import AOS scroll animation CSS (bundled by Astro)
import "aos/dist/aos.css";
// Define the props this layout accepts
interface Props {
title: string;
}
// Destructure the title prop passed from each page
const { title } = Astro.props;
---
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- Custom org favicon -->
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<!-- Dynamic page title with org suffix -->
<title>{title} | ASBG - Colegio de Muntinlupa</title>
</head>
<body class="min-h-screen bg-gray-50 text-gray-900">
<!-- Responsive navigation bar -->
<nav class="bg-gray-900 text-white">
<div class="max-w-6xl mx-auto px-4 py-4 flex items-center justify-between">
<!-- Logo and org name -->
<a href="/" class="flex items-center gap-2">
<img src="/favicon.svg" alt="ASBG Logo" class="w-8 h-8" />
<span class="text-lg font-bold">ASBG - CdM</span>
</a>
<!-- Hamburger button (visible on mobile only) -->
<button id="menu-toggle" class="sm:hidden flex flex-col gap-1.5 p-2" aria-label="Toggle menu">
<span class="block w-6 h-0.5 bg-white transition-transform" id="bar1"></span>
<span class="block w-6 h-0.5 bg-white transition-opacity" id="bar2"></span>
<span class="block w-6 h-0.5 bg-white transition-transform" id="bar3"></span>
</button>
<!-- Desktop nav links (hidden on mobile) -->
<div class="hidden sm:flex gap-6 text-sm">
<a href="/" class="hover:text-orange-400 transition-colors">Home</a>
<a href="/events/" class="hover:text-orange-400 transition-colors">Events</a>
<a href="/officers/" class="hover:text-orange-400 transition-colors">Officers</a>
</div>
</div>
<!-- Mobile nav menu (hidden by default, toggled by JS) -->
<div id="mobile-menu" class="hidden sm:hidden px-4 pb-4">
<div class="flex flex-col gap-3 text-sm">
<a href="/" class="hover:text-orange-400 transition-colors">Home</a>
<a href="/events/" class="hover:text-orange-400 transition-colors">Events</a>
<a href="/officers/" class="hover:text-orange-400 transition-colors">Officers</a>
</div>
</div>
</nav>
<!-- Page-specific content gets injected here -->
<main>
<slot />
</main>
<!-- Site footer -->
<footer class="bg-gray-900 text-gray-400 text-center py-6 mt-12">
<p class="text-sm">AWS Student Builder Group - Colegio de Muntinlupa</p>
<p class="text-xs mt-1">Built with Astro and hosted on AWS S3</p>
</footer>
<!-- AOS initialization (bundled by Astro as an ES module) -->
<script>
import AOS from 'aos';
AOS.init({
duration: 800,
easing: 'ease-out',
once: true
});
</script>
<!-- Hamburger menu toggle (is:inline keeps it in page scope) -->
<script is:inline>
document.addEventListener('DOMContentLoaded', function() {
var toggle = document.getElementById('menu-toggle');
var menu = document.getElementById('mobile-menu');
var bar1 = document.getElementById('bar1');
var bar2 = document.getElementById('bar2');
var bar3 = document.getElementById('bar3');
toggle.addEventListener('click', function() {
menu.classList.toggle('hidden');
bar1.classList.toggle('rotate-45');
bar1.classList.toggle('translate-y-2');
bar2.classList.toggle('opacity-0');
bar3.classList.toggle('-rotate-45');
bar3.classList.toggle('-translate-y-2');
});
});
</script>
</body>
</html>
What does this code do?
The layout now includes three new features: your custom logo, a responsive hamburger menu, and scroll animations.
- The <link rel="icon"> points to your SVG logo so the browser tab shows your org's icon instead of the Astro default.
- The hamburger button uses three <span> bars that animate into an X when clicked. It only appears on screens narrower than 640px (sm:hidden).
- AOS CSS is imported in the frontmatter so Astro bundles it. The JS is loaded via import AOS from 'aos' inside a <script> tag, which Astro processes as an ES module.
- The burger menu uses <script is:inline> so it runs directly in the browser's page scope. The DOMContentLoaded listener ensures the DOM elements exist before the script tries to find them.
- The once: true setting means animations only play the first time an element scrolls into view, not every time.
- Save src/layouts/Layout.astro.
- You will not see a visible change in the browser yet because no page is using the layout. The next chunk connects it.
Seeing a red underline in VS Code?
If VS Code highlights the .astro syntax, make sure you have the Astro extension installed. Search for Astro in the Extensions panel and install the official one by the Astro team.
Help me fix syntax errors in my .astro file
✔️ Awesome, I've got everything!
Great. Make sure you have saved the file and that your favicon appears in the browser tab.
ⓧ I'd like to double check the full code
Compare your Layout.astro against the code block above. Make sure the AOS CDN links, the hamburger button HTML, and the script block at the bottom are all present.
Build the home page with scroll animations
The src/pages/index.astro file is the home page of your site. Astro uses file-based routing, so any file inside src/pages/ automatically becomes a URL route.
- Open src/pages/index.astro in the VS Code Explorer sidebar.
- Select all the existing content and replace it with the following code:
---
// Import the shared layout component
import Layout from "../layouts/Layout.astro";
---
<Layout title="Home">
<!-- Hero section with org name, tagline, and call-to-action -->
<section class="bg-gradient-to-br from-gray-900 to-gray-700 text-white py-20 px-4">
<div class="max-w-4xl mx-auto text-center">
<img src="/favicon.svg" alt="ASBG Logo" class="w-20 h-20 mx-auto mb-6" data-aos="fade-down" />
<h1 class="text-3xl sm:text-5xl font-bold mb-4" data-aos="fade-up">AWS Student Builder Group</h1>
<p class="text-lg sm:text-xl text-gray-300 mb-2" data-aos="fade-up" data-aos-delay="100">Colegio de Muntinlupa</p>
<p class="text-base sm:text-lg text-gray-400 mt-6 max-w-2xl mx-auto" data-aos="fade-up" data-aos-delay="200">
Building the next generation of cloud practitioners. Learn, build, and grow together with AWS.
</p>
<a
href="/events/"
class="inline-block mt-8 px-6 py-3 bg-orange-500 hover:bg-orange-600 text-white font-semibold rounded-lg transition-colors"
data-aos="fade-up"
data-aos-delay="300"
>
View Upcoming Events
</a>
</div>
</section>
<!-- About Us section with two info cards -->
<section class="max-w-4xl mx-auto px-4 py-16">
<h2 class="text-2xl font-bold mb-6 text-center" data-aos="fade-up">About Us</h2>
<div class="grid sm:grid-cols-2 gap-8">
<div class="bg-white p-6 rounded-lg shadow-sm" data-aos="fade-up" data-aos-delay="100">
<h3 class="font-semibold text-lg mb-2">Our Mission</h3>
<p class="text-gray-600">
To empower students at Colegio de Muntinlupa with cloud computing skills through hands-on workshops, study groups, and community projects using Amazon Web Services.
</p>
</div>
<div class="bg-white p-6 rounded-lg shadow-sm" data-aos="fade-up" data-aos-delay="200">
<h3 class="font-semibold text-lg mb-2">What We Do</h3>
<p class="text-gray-600">
We host workshops, certification study groups, and collaborative cloud projects. Whether you are a beginner or already building on AWS, there is a place for you here.
</p>
</div>
</div>
</section>
</Layout>
What does this code do?
The home page now uses your logo in the hero section and has data-aos attributes on key elements. Each data-aos-delay value staggers the animations so elements fade in one after another instead of all at once.
The hero uses fade-down for the logo (drops in from above) and fade-up for the text and button (rise up from below). This creates a polished opening effect.
- Save src/pages/index.astro.
- Switch to your browser at http://localhost:4321. You should see a dark gradient hero section with the org name, a tagline, an orange button, and two white cards below.
Page still showing the old default content?
Make sure you replaced all the content in index.astro (select all first, then paste). Also confirm your dev server is still running in the terminal. If it stopped, run npm run dev again inside the asbg-website folder.
Help me debug why my home page is not updating
✔️ Awesome, I've got everything!
Great. Make sure you have saved the file and can see the animations when you scroll.
ⓧ I'd like to double check the full code
Compare your index.astro against the code block above. Make sure the data-aos and data-aos-delay attributes are present on the hero elements and the About Us cards.
Test the hamburger menu and animations
Your site now has a responsive hamburger menu and scroll animations. Let's confirm both work correctly on different screen sizes.
- In your browser at http://localhost:4321, drag the edge of the browser window to make it narrow (around phone-width).
- Confirm the nav links disappear and a three-line hamburger icon appears on the right.
- Click the hamburger icon.
- Confirm the mobile menu slides open with Home, Events, and Officers links.
- Click the icon again and confirm it closes (the bars animate into an X and back).
- Widen the browser back to full desktop size.
- Confirm the hamburger disappears and the nav links display inline again.
- Scroll down the page and confirm the About Us cards fade in smoothly as they enter the viewport.
How does the hamburger animation work?
The three bars are <span> elements with Tailwind transition classes. When clicked, JavaScript toggles rotate-45 and translate-y classes to rotate the top and bottom bars into an X shape, and opacity-0 hides the middle bar.
Your layout is ready with a custom logo, hamburger menu, and scroll animations. Next up, you will build the Events page to display upcoming and past events.
Build the Events Page
Your home page is live with a hero section and org info. Now it's time to give your site the content members will actually check regularly.
An events page lets members see what's happening next and what they missed. You'll create a data file to store event info, then build a page that reads and displays it automatically.
In this step, get ready to:
- Create an events data file with sample org events.
- Build the Events page that filters events into upcoming and past sections.
Create the events data file
Instead of hardcoding event details directly into HTML, you'll store them in a JSON file. Astro can import JSON files directly in frontmatter, which means your page reads from a single data source that's easy to update.
- In the VS Code Explorer sidebar, right-click the src folder.
- Click New Folder and name it data.
- Right-click the new src/data folder and click New File.
- Name the file events.json.
- Paste the following JSON data into events.json:
[
{
"title": "Org Launch Day",
"date": "2026-08-15",
"time": "10:00 AM - 12:00 PM",
"type": "launch",
"venue": "CDM Auditorium",
"description": "Official launch of the AWS Student Builder Group at Colegio de Muntinlupa. Meet the founding officers and learn about our plans for the academic year.",
"speakers": ["ASBG Founding Officers"],
"registrationLink": "https://forms.google.com/asbg-launch"
},
{
"title": "AWS Cloud Practitioner Study Group",
"date": "2026-08-22",
"time": "2:00 PM - 5:00 PM",
"type": "seminar",
"venue": "CDM Room 301",
"description": "Weekly study sessions to prepare for the AWS Certified Cloud Practitioner exam. Open to all skill levels.",
"speakers": ["Tech Office Lead"],
"registrationLink": "https://forms.google.com/asbg-cpp"
},
{
"title": "First Hands-On Workshop: Deploy a Website on S3",
"date": "2026-09-05",
"time": "1:00 PM - 4:00 PM",
"type": "workshop",
"venue": "CDM Computer Lab 2",
"description": "Learn how to build and deploy a static website on AWS S3. No prior cloud experience required.",
"speakers": ["Executive Director", "Tech Office Lead"],
"registrationLink": "https://forms.google.com/asbg-s3"
},
{
"title": "Founding Meeting",
"date": "2026-06-10",
"time": "3:00 PM - 4:30 PM",
"type": "meetup",
"venue": "CDM Room 205",
"description": "The initial meeting where founding officers came together to establish the AWS Student Builder Group.",
"speakers": ["Founding President"],
"registrationLink": ""
}
]
What does this data represent?
Each event object has these fields:
- title and description hold the display text for the event card.
- date stores the event date in ISO format (YYYY-MM-DD). The page compares this to today's date to auto-tag events as upcoming or done.
- time and venue tell members when and where to show up.
- type categorizes the event (workshop, seminar, meetup, or launch) so you can color-code badges.
- speakers is an array of speaker names displayed on the card.
- registrationLink links to a sign-up form (empty string for past events).
- Save events.json.
Seeing a red underline in VS Code?
If VS Code highlights a syntax error, check that every string is wrapped in double quotes (JSON does not allow single quotes). Also confirm there is no trailing comma after the last object in the array.
help me fix a JSON syntax error in my events.json file
✔️ Awesome, I've got everything!
Great. Make sure you've saved events.json before moving on.
ⓧ I'd like to double check the full code
[
{
"title": "Org Launch Day",
"date": "2026-08-15",
"type": "launch",
"description": "Official launch of the AWS Student Builder Group at Colegio de Muntinlupa. Meet the founding officers and learn about our plans for the academic year.",
"status": "upcoming"
},
{
"title": "AWS Cloud Practitioner Study Group",
"date": "2026-08-22",
"type": "workshop",
"description": "Weekly study sessions to prepare for the AWS Certified Cloud Practitioner exam. Open to all skill levels.",
"status": "upcoming"
},
{
"title": "First Hands-On Workshop: Deploy a Website on S3",
"date": "2026-09-05",
"type": "workshop",
"description": "Learn how to build and deploy a static website on AWS S3. No prior cloud experience required.",
"status": "upcoming"
},
{
"title": "Founding Meeting",
"date": "2026-06-10",
"type": "meetup",
"description": "The initial meeting where founding officers came together to establish the AWS Student Builder Group.",
"status": "past"
}
]
Build the Events page with filtering
Now you'll create the page that reads this data and renders it. Astro's file-based routing means any file in src/pages/ automatically becomes a URL route.
- Right-click the src/pages folder in the Explorer sidebar.
- Click New File and name it events.astro.
- Paste the following code into events.astro:
---
import Layout from "../layouts/Layout.astro";
import eventsData from "../data/events.json";
// Auto-tag events: compare event date to today
const today = new Date().toISOString().split('T')[0];
const upcoming = eventsData.filter((e) => e.date >= today);
const past = eventsData.filter((e) => e.date < today);
// Return the Tailwind classes for each event type badge
function getBadgeColor(type: string): string {
switch (type) {
case "workshop":
return "bg-blue-100 text-blue-800";
case "seminar":
return "bg-purple-100 text-purple-800";
case "meetup":
return "bg-green-100 text-green-800";
case "launch":
return "bg-orange-100 text-orange-800";
default:
return "bg-gray-100 text-gray-800";
}
}
---
<Layout title="Events">
<section class="max-w-6xl mx-auto px-4 py-12">
<h1 class="text-3xl font-bold mb-8 text-center" data-aos="fade-up">Events</h1>
<h2 class="text-xl font-semibold mb-4" data-aos="fade-up">Upcoming Events</h2>
{upcoming.length === 0 && <p class="text-gray-500 mb-8">No upcoming events yet. Check back soon!</p>}
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-6 mb-12">
{upcoming.map((event, i) => (
<div class="bg-white rounded-lg shadow-sm p-6 border border-gray-100" data-aos="fade-up" data-aos-delay={i * 100}>
<span class={`inline-block px-2 py-1 text-xs font-medium rounded ${getBadgeColor(event.type)}`}>
{event.type}
</span>
<h3 class="font-semibold text-lg mt-3 mb-1">{event.title}</h3>
<p class="text-sm text-gray-500 mb-1">{event.date} | {event.time}</p>
<p class="text-sm text-gray-500 mb-3">{event.venue}</p>
<p class="text-gray-600 text-sm mb-3">{event.description}</p>
{event.speakers.length > 0 && (
<p class="text-xs text-gray-400 mb-2">Speaker(s): {event.speakers.join(", ")}</p>
)}
{event.registrationLink && (
<a href={event.registrationLink} target="_blank" class="inline-block mt-2 px-4 py-2 bg-orange-500 hover:bg-orange-600 text-white text-sm font-medium rounded transition-colors">
Register
</a>
)}
</div>
))}
</div>
<h2 class="text-xl font-semibold mb-4" data-aos="fade-up">Past Events</h2>
{past.length === 0 && <p class="text-gray-500">No past events yet.</p>}
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-6">
{past.map((event, i) => (
<div class="bg-gray-50 rounded-lg p-6 border border-gray-200 opacity-80" data-aos="fade-up" data-aos-delay={i * 100}>
<div class="flex items-center gap-2 mb-2">
<span class={`inline-block px-2 py-1 text-xs font-medium rounded ${getBadgeColor(event.type)}`}>
{event.type}
</span>
<span class="inline-block px-2 py-1 text-xs font-medium rounded bg-red-100 text-red-800">
Done
</span>
</div>
<h3 class="font-semibold text-lg mt-1 mb-1">{event.title}</h3>
<p class="text-sm text-gray-500 mb-1">{event.date} | {event.time}</p>
<p class="text-sm text-gray-500 mb-3">{event.venue}</p>
<p class="text-gray-600 text-sm">{event.description}</p>
{event.speakers.length > 0 && (
<p class="text-xs text-gray-400 mt-2">Speaker(s): {event.speakers.join(", ")}</p>
)}
</div>
))}
</div>
</section>
</Layout>
What does this code do?
The key change here is auto-done tagging. Instead of a hardcoded status field, the page compares each event's date to today's date at build time. If the event date has passed, it goes into the "Past" section and gets a red "Done" badge automatically.
This means you never have to manually update an event's status. Once the date passes, it moves to the past section on the next deploy. The admin dashboard (built later) will handle creating events through DynamoDB.
- Save events.astro.
- Switch to your browser and navigate to http://localhost:4321/events/.
- You should see an "Upcoming Events" section with three event cards (Org Launch Day, AWS Cloud Practitioner Study Group, First Hands-On Workshop) and a "Past Events" section with one card (Founding Meeting).
Page not loading or showing errors?
- Check that your file is saved as events.astro inside src/pages/ (not inside src/data/ or the project root).
- Confirm events.json is inside src/data/. The import path ../data/events.json expects that exact location.
- If you see a blank page, open your terminal to check for Astro build errors. A missing comma or extra bracket in the JSON file will break the import.
help me debug why my Astro events page is not rendering
✔️ Awesome, I've got everything!
Great. Double check that you've saved both files before continuing.
ⓧ I'd like to double check the full code
---
import Layout from "../layouts/Layout.astro";
import eventsData from "../data/events.json";
const upcoming = eventsData.filter((e) => e.status === "upcoming");
const past = eventsData.filter((e) => e.status === "past");
function getBadgeColor(type: string): string {
switch (type) {
case "workshop":
return "bg-blue-100 text-blue-800";
case "meetup":
return "bg-green-100 text-green-800";
case "launch":
return "bg-orange-100 text-orange-800";
default:
return "bg-gray-100 text-gray-800";
}
}
---
<Layout title="Events">
<section class="max-w-6xl mx-auto px-4 py-12">
<h1 class="text-3xl font-bold mb-8 text-center">Events</h1>
<h2 class="text-xl font-semibold mb-4">Upcoming Events</h2>
{upcoming.length === 0 && <p class="text-gray-500 mb-8">No upcoming events yet. Check back soon!</p>}
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-6 mb-12">
{upcoming.map((event) => (
<div class="bg-white rounded-lg shadow-sm p-6 border border-gray-100">
<span class={`inline-block px-2 py-1 text-xs font-medium rounded ${getBadgeColor(event.type)}`}>
{event.type}
</span>
<h3 class="font-semibold text-lg mt-3 mb-1">{event.title}</h3>
<p class="text-sm text-gray-500 mb-3">{event.date}</p>
<p class="text-gray-600 text-sm">{event.description}</p>
</div>
))}
</div>
<h2 class="text-xl font-semibold mb-4">Past Events</h2>
{past.length === 0 && <p class="text-gray-500">No past events yet.</p>}
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-6">
{past.map((event) => (
<div class="bg-gray-50 rounded-lg p-6 border border-gray-200 opacity-80">
<span class={`inline-block px-2 py-1 text-xs font-medium rounded ${getBadgeColor(event.type)}`}>
{event.type}
</span>
<h3 class="font-semibold text-lg mt-3 mb-1">{event.title}</h3>
<p class="text-sm text-gray-500 mb-3">{event.date}</p>
<p class="text-gray-600 text-sm">{event.description}</p>
</div>
))}
</div>
</section>
</Layout>
Why store data in JSON instead of HTML?
With a JSON data file, adding a new event is as simple as adding a new object to the array. You never have to touch the page's layout or styling code.
This separation of data from presentation is a core pattern in web development. It keeps your code maintainable as the org grows and events pile up.
Your site now has real content that members can browse. Next up, you'll build the Officers page to showcase the org's leadership team.
Build the Officers Page
Build the Officers Page Frontend
Your Events page is live and looking great. Now it is time to build the showcase for your org's leadership structure.
The Officers page has two main sections. The top spotlights the President (current highlighted large, past presidents smaller below). The bottom displays 7 office divisions (Executive, Finance, Creatives, Operation, Marketing, Relations, Technology), each showing their Director and Assistant Director highlighted, with members listed below. For now, you will build the frontend layout with placeholder data. In later steps, you will connect it to a real database.
In this step, get ready to:
- Create the officers placeholder data file with the new organizational structure.
- Build the President spotlight section with current and historical presidents.
- Build the office divisions section with Director, Assistant Director, and member cards.
Create the officers data file
The Officers page needs structured data that represents your org's hierarchy: presidents at the top level, then officers grouped by office. For now, you will use a local JSON file with placeholder data. Later, this will be replaced by live API calls to DynamoDB.
- In the VS Code Explorer sidebar, right-click the src/data folder.
- Click New File and name it officers.json.
- Paste the following officer data into the new file:
{
"presidents": [
{
"name": "Officer Name",
"program": "BS Computer Science",
"yearLevel": "4th Year",
"position": "Founding President / Executive Director",
"email": "founder@cdm.edu",
"photoUrl": "https://placehold.co/200x200/86efac/1f2937?text=President",
"term": "2026-2027",
"isCurrent": true
}
],
"offices": [
{
"name": "Executive",
"members": [
{
"name": "Officer Name",
"program": "BS Information Technology",
"yearLevel": "3rd Year",
"position": "Director",
"role": "Executive Director",
"email": "exec.director@cdm.edu",
"photoUrl": "https://placehold.co/200x200/86efac/1f2937?text=Dir"
},
{
"name": "Officer Name",
"program": "BS Computer Science",
"yearLevel": "2nd Year",
"position": "Assistant Director",
"role": "Asst. Executive Director",
"email": "exec.asst@cdm.edu",
"photoUrl": "https://placehold.co/200x200/fbcfe8/1f2937?text=Asst"
},
{
"name": "Member Name",
"program": "BS Information Technology",
"yearLevel": "2nd Year",
"position": "Member",
"role": "Executive Staff",
"email": "exec.member@cdm.edu",
"photoUrl": "https://placehold.co/200x200/f3f4f6/1f2937?text=Member"
}
]
},
{
"name": "Finance",
"members": [
{
"name": "Officer Name",
"program": "BS Accountancy",
"yearLevel": "3rd Year",
"position": "Director",
"role": "Finance Director",
"email": "finance.director@cdm.edu",
"photoUrl": "https://placehold.co/200x200/86efac/1f2937?text=Dir"
}
]
},
{
"name": "Creatives",
"members": []
},
{
"name": "Operation",
"members": []
},
{
"name": "Marketing",
"members": []
},
{
"name": "Relations",
"members": []
},
{
"name": "Technology",
"members": []
}
]
}
What does this data structure do?
The JSON has two top-level sections. The presidents array holds all historical presidents with an isCurrent flag to highlight the active one. The offices array groups members by their division.
Each person has name, program, yearLevel, position (Director, Assistant Director, or Member), role (their specific title), email, and photoUrl. The placeholder images use the strawberry matcha colors (green for directors, pink for assistants).
- Save officers.json.
Build the Officers page
Now you will create the page that reads this data and renders each officer as a profile card. The page follows the same pattern as the Events page: import JSON in the frontmatter, then loop through it in the template.
- In the Explorer sidebar, right-click the src/pages folder.
- Click New File and name it officers.astro.
- Paste the following code into the new file:
---
import Layout from "../layouts/Layout.astro";
import officersData from "../data/officers.json";
// Separate current and past presidents
const currentPresident = officersData.presidents.find(p => p.isCurrent);
const pastPresidents = officersData.presidents.filter(p => !p.isCurrent);
---
<Layout title="Officers">
<section class="max-w-6xl mx-auto px-4 py-12">
<h1 class="text-3xl font-bold mb-2 text-center">Officers</h1>
<p class="text-gray-500 text-center mb-10">AWS Student Builder Group | CDM</p>
<!-- President Spotlight -->
<div class="mb-16">
<h2 class="text-2xl font-bold text-center mb-8 text-green-700">President</h2>
<!-- Current President (highlighted large) -->
{currentPresident && (
<div class="bg-gradient-to-br from-green-50 to-pink-50 rounded-xl shadow-md p-8 text-center max-w-md mx-auto mb-8 border-2 border-green-300">
<img
src={currentPresident.photoUrl}
alt={currentPresident.name}
class="w-32 h-32 rounded-full mx-auto mb-4 object-cover border-4 border-green-400"
/>
<h3 class="font-bold text-xl">{currentPresident.name}</h3>
<p class="text-green-700 font-semibold text-sm">{currentPresident.position}</p>
<p class="text-gray-600 text-sm mt-1">{currentPresident.program} | {currentPresident.yearLevel}</p>
<p class="text-gray-400 text-xs mt-1">{currentPresident.email}</p>
<span class="inline-block mt-3 px-3 py-1 bg-green-100 text-green-800 text-xs rounded-full font-medium">
Current | {currentPresident.term}
</span>
</div>
)}
<!-- Past Presidents (smaller row) -->
{pastPresidents.length > 0 && (
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-4 max-w-3xl mx-auto">
{pastPresidents.map((pres) => (
<div class="bg-white rounded-lg shadow-sm p-4 text-center border border-gray-100">
<img
src={pres.photoUrl}
alt={pres.name}
class="w-16 h-16 rounded-full mx-auto mb-2 object-cover"
/>
<h4 class="font-semibold text-sm">{pres.name}</h4>
<p class="text-gray-500 text-xs">{pres.term}</p>
</div>
))}
</div>
)}
</div>
<!-- Office Divisions -->
{officersData.offices.map((office) => (
<div class="mb-12">
<h2 class="text-xl font-bold mb-6 text-pink-700 border-b-2 border-pink-200 pb-2">
{office.name}
</h2>
{office.members.length > 0 ? (
<div class="grid sm:grid-cols-2 lg:grid-cols-3 gap-6">
{office.members.map((member) => (
<div class:list={[
"rounded-lg shadow-sm p-5 text-center border",
member.position === "Director"
? "bg-gradient-to-br from-green-50 to-white border-green-200"
: member.position === "Assistant Director"
? "bg-gradient-to-br from-pink-50 to-white border-pink-200"
: "bg-white border-gray-100"
]}>
<img
src={member.photoUrl}
alt={member.name}
class="w-20 h-20 rounded-full mx-auto mb-3 object-cover"
/>
<h3 class="font-semibold text-base">{member.name}</h3>
<p class:list={[
"font-medium text-sm",
member.position === "Director"
? "text-green-700"
: member.position === "Assistant Director"
? "text-pink-600"
: "text-gray-600"
]}>{member.role}</p>
<p class="text-gray-500 text-xs mt-1">{member.program} | {member.yearLevel}</p>
<p class="text-gray-400 text-xs mt-1">{member.email}</p>
</div>
))}
</div>
) : (
<p class="text-gray-400 italic text-sm">No members assigned yet.</p>
)}
</div>
))}
</section>
</Layout>
What does this code do?
The frontmatter separates presidents into current (highlighted) and past (smaller row). The template renders two major sections: the President spotlight at the top and the office divisions below.
Directors get a green gradient card, Assistant Directors get a pink gradient card, and regular members get a plain white card. This visual hierarchy makes leadership instantly clear at a glance using the strawberry matcha color scheme.
- Save officers.astro.
- Switch to your browser and navigate to http://localhost:4321/officers/.
- You should see the President spotlight at the top with a large green-bordered card, followed by 7 office division sections. The Executive office should show its Director (green card), Assistant Director (pink card), and Member (white card).
Page showing a 404?
Make sure your file is named exactly officers.astro and is inside src/pages/. Astro uses file-based routing, so the file path must match the URL.
Also check that your dev server is still running in the terminal. If it stopped, run npm run dev again.
Why isn't my Officers page showing up at /officers/?
✔️ Awesome, I've got everything!
Great. Double check you have saved both files before moving on.
ⓧ I'd like to double check the full code
The full officers.json and officers.astro files are too long to display here. Compare your code against the code blocks above and make sure both files are saved.
Your Officers page frontend is ready with the full org structure. Right now it reads from a local JSON file. Next up, you will create a DynamoDB table to store officer data in the cloud so the page can pull live data from a real database.
Set Up DynamoDB
Your website frontend is complete with the Home, Events, and Officers pages. Right now, all data lives in local JSON files that require code changes and a redeploy every time something changes.
A real organization needs databases that can be updated without touching code. You will create three DynamoDB tables: one for members, one for officers, and one for events. This separates your data from your frontend and lets the admin dashboard manage everything dynamically.
In this step, get ready to:
- Create a DynamoDB table for members.
- Create a DynamoDB table for officers.
- Create a DynamoDB table for events.
Create the members table
The members table stores every registered member of the org. Each member gets a unique ID that acts as their membership proof. The partition key groups all members together, and the sort key identifies each individual.
- Open the AWS Management Console in your browser at https://console.aws.amazon.com.
- Search for DynamoDB in the top search bar and select DynamoDB from the results.
- Click Create table.
- For Table name, enter asbg-members.
- For Partition key, enter PK and leave the type as String.
- For Sort key, enter SK and leave the type as String.
- Under Table settings, select Customize settings.
- Under Read/write capacity settings, select On-demand.
- Leave all other settings as default and click Create table.
Why on-demand capacity?
On-demand mode means you only pay for the read and write requests you actually use. For a student org website with low traffic, this stays well within the AWS free tier (25 read/write capacity units per table).
The PK (partition key) groups related items. For the members table, all records share PK=MEMBER. The SK (sort key) uniquely identifies each member using their generated ID like MEM#7f2a9c.
Wait for the table status to change from Creating to Active. This usually takes 5-10 seconds.
Create the officers and events tables
Now repeat the same process to create two more tables. Each uses the same PK and SK key schema with on-demand capacity.
- Go back to the DynamoDB Tables list in the left sidebar.
- Click Create table again.
- For the second table, enter asbg-officers as the table name.
- Use the same key schema: PK (String) as partition key, SK (String) as sort key.
- Select Customize settings and choose On-demand capacity.
- Click Create table and wait for it to become Active.
- Create the third table with the name asbg-events using the same settings.
{
"PK": {"S": "MEMBER"},
"SK": {"S": "MEM#a1b2c3"},
"name": {"S": "Juan Dela Cruz"},
"personalEmail": {"S": "juan@gmail.com"},
"cdmEmail": {"S": "juan.delacruz@cdm.edu"},
"studentNumber": {"S": "2023-00123"},
"birthday": {"S": "2003-05-15"},
"yearLevel": {"S": "3rd Year"},
"program": {"S": "BS Computer Science"},
"memberId": {"S": "a1b2c3"}
}
- Click Create item again in the asbg-officers table and paste this officer record:
{
"PK": {"S": "OFFICE#Executive"},
"SK": {"S": "OFFICER#a1b2c3"},
"memberId": {"S": "a1b2c3"},
"name": {"S": "Juan Dela Cruz"},
"program": {"S": "BS Computer Science"},
"yearLevel": {"S": "3rd Year"},
"email": {"S": "juan.delacruz@cdm.edu"},
"position": {"S": "Director"},
"role": {"S": "Executive Director"},
"office": {"S": "Executive"},
"photoUrl": {"S": "https://placehold.co/200x200/86efac/1f2937?text=Dir"},
"term": {"S": "2026-2027"}
}
Why does the officer reference a memberId?
Notice how the officer record includes a memberId field that matches the member's unique ID. This enforces the rule that a person must be a member before becoming an officer. The admin dashboard will use this link to only show existing members when promoting someone to officer.
DynamoDB uses typed JSON where {"S": "value"} means a String. Your Lambda functions will handle the conversion to plain JSON for the frontend.
- Go back to the Tables list. You should see all three tables (asbg-members, asbg-officers, asbg-events) with Active status.
Don't see your items?
Click Run (the orange button) in the Scan/Query section to refresh the table view. Make sure you are viewing the correct table (asbg-officers).
Help me find my DynamoDB items
Your three databases are ready. Next up, you will set up AWS Cognito to protect your admin dashboard with proper authentication before building the Lambda functions.
Set Up AWS Cognito
Your databases are in place. But right now, anyone who finds your API URLs could modify your data. Before building the Lambda functions, you need a way to verify that only authorized admins can create, update, or delete records.
AWS Cognito is a managed authentication service. It handles user sign-up, sign-in, and token generation. You will create a user pool with one admin user, then use the issued JWT tokens to protect your API endpoints.
In this step, get ready to:
- Create a Cognito User Pool for admin authentication.
- Create an app client for your frontend to communicate with Cognito.
- Create your first admin user account.
Create the user pool
A User Pool is Cognito's user directory. It stores admin credentials, handles password policies, and issues authentication tokens.
- In the AWS Console, search for Cognito and select Amazon Cognito.
- Click Create user pool.
- Under Sign-in experience, select Email as the sign-in option.
- Under Security requirements, keep the default password policy (minimum 8 characters, requires uppercase, lowercase, number, and symbol).
- Under Multi-factor authentication, select No MFA for now (you can enable this later for extra security).
- Under Self-service sign-up, uncheck Enable self-registration. Only you will manually create admin accounts.
- Click Next.
Why disable self-registration?
Self-registration would let anyone create an admin account. Since your dashboard controls sensitive member data, only existing admins should be able to create new admin users manually through the Cognito console.
- On the Configure sign-up experience page, leave all defaults and click Next.
- On the Configure message delivery page, select Send email with Cognito (the default free option).
- Click Next.
Configure the app client
The app client is how your frontend communicates with Cognito. It defines which authentication flows are allowed and generates the client ID your login page will use.
- On the Integrate your app page, for User pool name, enter asbg-admin-pool.
- Under Initial app client, for App client name, enter asbg-admin-client.
- Leave the client secret setting as Don't generate a client secret (browser-based apps cannot securely store secrets).
- Click Next.
- Review the summary and click Create user pool.
Create your admin user
- Click your new asbg-admin-pool user pool to open it.
- Click the Users tab.
- Click Create user.
- For Email address, enter your admin email.
- Select Send an invitation and Set a temporary password.
- Enter a temporary password you will remember.
- Click Create user.
Note down your User Pool ID (shown at the top of the user pool page) and your App Client ID (found under the App integration tab). You will need both when building the admin login page.
Your authentication system is ready. Next up, you will set up AWS SES so the system can send membership confirmation emails to new members.
Set Up AWS SES
Your authentication is configured. Now you need a way to send emails when a new member is registered. The confirmation email serves as proof of membership and includes their unique member ID.
AWS SES (Simple Email Service) lets you send transactional emails programmatically. You will verify your sending identity and create an email template that Lambda will use to send membership confirmations.
In this step, get ready to:
- Verify your email identity in SES.
- Create a membership confirmation email template.
- Test sending a sample email.
Verify your email identity
SES requires you to verify the email address you send from. In sandbox mode (default for new accounts), you also need to verify recipient addresses. For production, you can request to move out of sandbox.
- In the AWS Console, search for SES and select Amazon Simple Email Service.
- In the left sidebar, click Verified identities.
- Click Create identity.
- Select Email address as the identity type.
- Enter the email address you want to send from (e.g., your CDM admin email).
- Click Create identity.
- Check your email inbox for the verification email from AWS and click the verification link.
What is SES sandbox mode?
New SES accounts start in sandbox mode. This means you can only send emails to verified addresses. For testing, verify your own email as both sender and recipient. Once your site is live, you can request production access to send to any address.
Create the membership email template
SES templates let you define reusable email layouts with placeholders. Your Lambda function will fill in the member's name and ID at send time.
- Open CloudShell by clicking the terminal icon in the top-right of the AWS Console.
- Create the email template by running this command:
aws ses create-template --template '{
"TemplateName": "MembershipConfirmation",
"SubjectPart": "Welcome to ASBG! Your Membership ID: {{memberId}}",
"HtmlPart": "<html><body><h1>Welcome to ASBG, {{name}}!</h1><p>You are now an official member of the AWS Student Builder Group - Colegio de Muntinlupa.</p><p><strong>Your Membership ID:</strong> {{memberId}}</p><p><strong>Student Number:</strong> {{studentNumber}}</p><p><strong>Program:</strong> {{program}}</p><p>Keep this email as proof of your membership. See you at our next event!</p><p>- ASBG Admin Team</p></body></html>",
"TextPart": "Welcome to ASBG, {{name}}! Your Membership ID: {{memberId}}. Student Number: {{studentNumber}}. Program: {{program}}. Keep this email as proof of your membership."
}'
What are template placeholders?
The {{name}}, {{memberId}}, and other {{...}} values are placeholders. When your Lambda function sends the email, it passes the actual member data and SES fills in the template automatically.
- Verify the template was created by running:
aws ses get-template --template-name MembershipConfirmation
You should see the template details returned as JSON.
Your email system is ready. Next up, you will build the Lambda functions that handle all CRUD operations and trigger these confirmation emails.
Build the Lambda Functions
Your SES template is ready to send emails and Cognito is guarding the gate. Now you need the backend logic that ties everything together.
AWS Lambda lets you run backend code without managing servers. You will create functions for member registration (with email), officer CRUD, and event CRUD. Each function reads from and writes to your DynamoDB tables, and the member registration function triggers SES to send the confirmation email.
In this step, get ready to:
- Create Lambda functions for member registration with SES email.
- Create Lambda functions for officer and event CRUD operations.
- Configure IAM permissions and API Gateway with Cognito authorization.
Create the GET officers function
This function queries your DynamoDB table and returns all officer data as JSON. The frontend will call this function to populate the Officers page with live data instead of the static JSON file.
- In the AWS Console, search for Lambda and select Lambda.
- Click Create function.
- Select Author from scratch.
- For Function name, enter asbg-getOfficers.
- For Runtime, select Python 3.12.
- Expand Change default execution role and select Create a new role with basic Lambda permissions.
- Click Create function.
After creation, you will see the function's code editor. Replace the default code with the following:
- Select all existing code in the editor and delete it.
- Paste the following code into the editor:
import json
import boto3
from boto3.dynamodb.conditions import Key
# Initialize DynamoDB resource
dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('asbg-officers')
def lambda_handler(event, context):
"""Fetch all officers grouped by presidents and offices."""
try:
# Scan the entire table (fine for small datasets)
response = table.scan()
items = response['Items']
# Separate presidents from office members
presidents = []
offices = {}
for item in items:
pk = item['PK']
if pk == 'PRESIDENT':
presidents.append(item)
elif pk.startswith('OFFICE#'):
office_name = pk.replace('OFFICE#', '')
if office_name not in offices:
offices[office_name] = []
offices[office_name].append(item)
# Build the response structure
result = {
'presidents': sorted(presidents, key=lambda x: x.get('term', ''), reverse=True),
'offices': [
{'name': name, 'members': offices.get(name, [])}
for name in ['Executive', 'Finance', 'Creatives', 'Operation', 'Marketing', 'Relations', 'Technology']
]
}
return {
'statusCode': 200,
'headers': {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
},
'body': json.dumps(result, default=str)
}
except Exception as e:
return {
'statusCode': 500,
'headers': {'Access-Control-Allow-Origin': '*'},
'body': json.dumps({'error': str(e)})
}
What does this code do?
The function scans the entire DynamoDB table (efficient for small datasets like a student org), separates records into presidents and office members based on the partition key prefix, then returns them in the exact structure your frontend expects.
The Access-Control-Allow-Origin header enables CORS so your website (hosted on a different domain) can call this function.
- Click Deploy to save the function.
Create the POST officer function
This function handles new officer registrations from your admin page. It receives officer data (name, program, office, position, etc.) and writes it to DynamoDB.
- Go back to the Lambda functions list by clicking Functions in the left sidebar.
- Click Create function again.
- For Function name, enter asbg-registerOfficer.
- For Runtime, select Python 3.12.
- Use the same default execution role option.
- Click Create function.
- Replace the default code with the following:
import json
import boto3
import uuid
# Initialize DynamoDB resource
dynamodb = boto3.resource('dynamodb')
table = dynamodb.Table('asbg-officers')
def lambda_handler(event, context):
"""Register a new officer or member into the database."""
try:
body = json.loads(event['body'])
# Validate required fields
required = ['name', 'program', 'yearLevel', 'position', 'email', 'category']
for field in required:
if field not in body:
return {
'statusCode': 400,
'headers': {'Access-Control-Allow-Origin': '*'},
'body': json.dumps({'error': f'Missing required field: {field}'})
}
# Build the DynamoDB item based on category
category = body['category']
member_id = str(uuid.uuid4())[:8]
if category == 'president':
item = {
'PK': 'PRESIDENT',
'SK': f'PRES#{member_id}',
'name': body['name'],
'program': body['program'],
'yearLevel': body['yearLevel'],
'position': body.get('role', 'President'),
'email': body['email'],
'photoUrl': body.get('photoUrl', ''),
'term': body.get('term', ''),
'isCurrent': body.get('isCurrent', False)
}
else:
# Office member (Director, Assistant Director, or Member)
office = body.get('office', 'Executive')
item = {
'PK': f'OFFICE#{office}',
'SK': f'MEMBER#{member_id}',
'name': body['name'],
'program': body['program'],
'yearLevel': body['yearLevel'],
'position': body['position'],
'role': body.get('role', ''),
'email': body['email'],
'photoUrl': body.get('photoUrl', '')
}
# Write to DynamoDB
table.put_item(Item=item)
return {
'statusCode': 201,
'headers': {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
},
'body': json.dumps({'message': 'Officer registered successfully', 'id': member_id})
}
except Exception as e:
return {
'statusCode': 500,
'headers': {'Access-Control-Allow-Origin': '*'},
'body': json.dumps({'error': str(e)})
}
What does this code do?
The function reads the request body, validates required fields, then builds a DynamoDB item with the correct partition key based on whether the person is a president or an office member. The category field from the admin form determines which path to take.
Input validation is a key security practice. It prevents malformed data from reaching your database and gives clear error messages back to the admin form.
- Click Deploy to save.
Grant DynamoDB permissions to both functions
By default, Lambda functions cannot access DynamoDB. You need to attach an IAM policy that grants read/write access to your table.
- Open the asbg-getOfficers function.
- Click the Configuration tab.
- Click Permissions in the left sidebar.
- Click the role name link under Execution role (it opens in a new tab in the IAM console).
- Click Add permissions, then Attach policies.
- Search for AmazonDynamoDBFullAccess and check the box next to it.
- Click Add permissions.
- Repeat the same steps for the asbg-registerOfficer function's execution role.
Why attach permissions separately?
AWS follows the principle of least privilege. Each Lambda function gets its own IAM role, and you explicitly grant only the permissions it needs. In production, you would scope this to just your specific table instead of full DynamoDB access.
🙋♀️ Can't find the execution role?
Make sure you are on the Configuration tab (not the Code tab). The role name is a clickable link that looks like asbg-getOfficers-role-xxxxxxxx.
Help me find my Lambda execution role
Your serverless backend is complete. Next up, you will build the admin dashboard frontend that calls these APIs.
Build the Admin Dashboard
Your backend APIs are running and protected by Cognito. Now you need a user-friendly interface so non-technical admins can manage the org without opening the AWS Console or editing code.
You will build a login-protected admin dashboard at /admin/ with tabs for managing Members, Officers, and Events. The dashboard calls your Lambda APIs and handles Cognito authentication in the browser.
In this step, get ready to:
- Build the admin login page with Cognito authentication.
- Build the dashboard with tabs for Members, Officers, and Events.
- Connect the dashboard forms to your Lambda APIs.
Install the Amplify Auth library
The admin page needs to authenticate with your Cognito user pool. The aws-amplify library handles the sign-in flow and token management in the browser.
- Install the Amplify library by running this command in your VS Code terminal:
npm install aws-amplify
What does this library do?
The aws-amplify package provides a JavaScript SDK that communicates with Cognito. It handles the sign-in request, receives JWT tokens, and stores them so your API calls can include the token in the Authorization header.
- You should see added 1 package (or similar) in the terminal output confirming the install succeeded.
Install failed?
Make sure you are running the command from inside your project folder (the folder containing package.json). If you see permission errors on macOS, prefix with sudo.
Help me fix my npm install error
Build the admin dashboard page
The dashboard is a single Astro page with client-side JavaScript. It shows a login form first, then reveals a tabbed interface with data tables and modal dialogs for adding or editing records.
- In the VS Code Explorer sidebar, right-click src/pages and select New Folder.
- Name the folder admin.
- Right-click the new admin folder and select New File.
- Name it index.astro.
- Paste the following code into src/pages/admin/index.astro:
---
import Layout from "../../layouts/Layout.astro";
---
<Layout title="Admin Dashboard">
<!-- Login Section -->
<section id="login-section" class="max-w-md mx-auto px-4 py-20">
<h1 class="text-2xl font-bold mb-6 text-center">Admin Login</h1>
<form id="login-form" class="space-y-4">
<div>
<label class="block text-sm font-medium mb-1" for="email">Email</label>
<input
type="email"
id="email"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-orange-500"
placeholder="admin@awssbg-cdm.live"
required
/>
</div>
<div>
<label class="block text-sm font-medium mb-1" for="password">Password</label>
<input
type="password"
id="password"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-orange-500"
required
/>
</div>
<p id="login-error" class="text-red-600 text-sm hidden"></p>
<button
type="submit"
class="w-full bg-orange-500 hover:bg-orange-600 text-white font-semibold py-2 rounded-lg transition-colors"
>
Sign In
</button>
</form>
</section>
<!-- Dashboard Section (hidden until authenticated) -->
<section id="dashboard-section" class="max-w-6xl mx-auto px-4 py-10 hidden">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold">Admin Dashboard</h1>
<button
id="logout-btn"
class="text-sm text-gray-600 hover:text-red-600 transition-colors"
>
Sign Out
</button>
</div>
<!-- Tab Navigation -->
<div class="border-b border-gray-200 mb-6">
<nav class="flex space-x-6">
<button class="tab-btn pb-2 border-b-2 border-orange-500 font-semibold text-orange-600" data-tab="members">Members</button>
<button class="tab-btn pb-2 border-b-2 border-transparent text-gray-500 hover:text-gray-700" data-tab="officers">Officers</button>
<button class="tab-btn pb-2 border-b-2 border-transparent text-gray-500 hover:text-gray-700" data-tab="events">Events</button>
</nav>
</div>
<!-- Members Tab -->
<div id="tab-members" class="tab-content">
<div class="flex justify-between items-center mb-4">
<h2 class="text-lg font-semibold">Members</h2>
<button class="add-btn bg-orange-500 hover:bg-orange-600 text-white px-4 py-2 rounded-lg text-sm" data-entity="member">Add Member</button>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm text-left">
<thead class="bg-gray-50 border-b">
<tr>
<th class="px-4 py-3">Name</th>
<th class="px-4 py-3">CDM Email</th>
<th class="px-4 py-3">Student No.</th>
<th class="px-4 py-3">Year Level</th>
<th class="px-4 py-3">Program</th>
<th class="px-4 py-3">Actions</th>
</tr>
</thead>
<tbody id="members-table" class="divide-y"></tbody>
</table>
</div>
</div>
<!-- Officers Tab -->
<div id="tab-officers" class="tab-content hidden">
<div class="flex justify-between items-center mb-4">
<h2 class="text-lg font-semibold">Officers</h2>
<button class="add-btn bg-orange-500 hover:bg-orange-600 text-white px-4 py-2 rounded-lg text-sm" data-entity="officer">Add Officer</button>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm text-left">
<thead class="bg-gray-50 border-b">
<tr>
<th class="px-4 py-3">Name</th>
<th class="px-4 py-3">Office</th>
<th class="px-4 py-3">Position</th>
<th class="px-4 py-3">Role</th>
<th class="px-4 py-3">Actions</th>
</tr>
</thead>
<tbody id="officers-table" class="divide-y"></tbody>
</table>
</div>
</div>
<!-- Events Tab -->
<div id="tab-events" class="tab-content hidden">
<div class="flex justify-between items-center mb-4">
<h2 class="text-lg font-semibold">Events</h2>
<button class="add-btn bg-orange-500 hover:bg-orange-600 text-white px-4 py-2 rounded-lg text-sm" data-entity="event">Add Event</button>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm text-left">
<thead class="bg-gray-50 border-b">
<tr>
<th class="px-4 py-3">Title</th>
<th class="px-4 py-3">Type</th>
<th class="px-4 py-3">Date</th>
<th class="px-4 py-3">Venue</th>
<th class="px-4 py-3">Actions</th>
</tr>
</thead>
<tbody id="events-table" class="divide-y"></tbody>
</table>
</div>
</div>
</section>
<!-- Modal Overlay -->
<div id="modal-overlay" class="fixed inset-0 bg-black/50 hidden z-50 flex items-center justify-center p-4">
<div class="bg-white rounded-lg w-full max-w-lg max-h-[90vh] overflow-y-auto">
<div class="flex justify-between items-center p-6 border-b">
<h3 id="modal-title" class="text-lg font-semibold"></h3>
<button id="modal-close" class="text-gray-400 hover:text-gray-600 text-2xl leading-none">×</button>
</div>
<form id="modal-form" class="p-6 space-y-4">
<div id="modal-fields"></div>
<div class="flex space-x-3 pt-4">
<button type="submit" class="flex-1 bg-orange-500 hover:bg-orange-600 text-white font-semibold py-2 rounded-lg transition-colors">Save</button>
<button type="button" id="modal-cancel" class="flex-1 border border-gray-300 text-gray-700 py-2 rounded-lg hover:bg-gray-50 transition-colors">Cancel</button>
</div>
</form>
</div>
</div>
</Layout>
<script>
import { Amplify } from 'aws-amplify';
import { signIn, signOut, fetchAuthSession } from 'aws-amplify/auth';
// Configure Amplify with your Cognito user pool
Amplify.configure({
Auth: {
Cognito: {
userPoolId: '[[USER_POOL_ID="your Cognito User Pool ID"]]',
userPoolClientId: '[[USER_POOL_CLIENT_ID="your Cognito App Client ID"]]',
}
}
});
// API base URL for your Lambda functions
const API_BASE = '[[API_BASE_URL="https://your-api-gateway-url.execute-api.ap-southeast-1.amazonaws.com/prod"]]';
// State
let token = '';
let currentEntity = 'member';
let editingId: string | null = null;
// DOM references
const loginSection = document.getElementById('login-section')!;
const dashboardSection = document.getElementById('dashboard-section')!;
const loginForm = document.getElementById('login-form') as HTMLFormElement;
const loginError = document.getElementById('login-error')!;
const logoutBtn = document.getElementById('logout-btn')!;
const modalOverlay = document.getElementById('modal-overlay')!;
const modalTitle = document.getElementById('modal-title')!;
const modalForm = document.getElementById('modal-form') as HTMLFormElement;
const modalFields = document.getElementById('modal-fields')!;
const modalClose = document.getElementById('modal-close')!;
const modalCancel = document.getElementById('modal-cancel')!;
// Field definitions for each entity type
const entityFields: Record<string, { name: string; label: string; type: string; required: boolean }[]> = {
member: [
{ name: 'name', label: 'Full Name', type: 'text', required: true },
{ name: 'personalEmail', label: 'Personal Email', type: 'email', required: true },
{ name: 'cdmEmail', label: 'CDM Email', type: 'email', required: true },
{ name: 'studentNumber', label: 'Student Number', type: 'text', required: true },
{ name: 'birthday', label: 'Birthday', type: 'date', required: true },
{ name: 'yearLevel', label: 'Year Level', type: 'number', required: true },
{ name: 'program', label: 'Program', type: 'text', required: true },
],
officer: [
{ name: 'name', label: 'Full Name', type: 'text', required: true },
{ name: 'office', label: 'Office', type: 'text', required: true },
{ name: 'position', label: 'Position', type: 'text', required: true },
{ name: 'role', label: 'Role', type: 'text', required: true },
{ name: 'photoUrl', label: 'Photo URL', type: 'url', required: false },
],
event: [
{ name: 'title', label: 'Event Title', type: 'text', required: true },
{ name: 'eventType', label: 'Type', type: 'text', required: true },
{ name: 'date', label: 'Date', type: 'date', required: true },
{ name: 'time', label: 'Time', type: 'time', required: true },
{ name: 'venue', label: 'Venue', type: 'text', required: true },
{ name: 'speakers', label: 'Speakers', type: 'text', required: false },
{ name: 'registrationLink', label: 'Registration Link', type: 'url', required: false },
],
};
// --- Authentication ---
loginForm.addEventListener('submit', async (e) => {
e.preventDefault();
loginError.classList.add('hidden');
const email = (document.getElementById('email') as HTMLInputElement).value;
const password = (document.getElementById('password') as HTMLInputElement).value;
try {
await signIn({ username: email, password });
const session = await fetchAuthSession();
token = session.tokens?.idToken?.toString() || '';
showDashboard();
} catch (err: any) {
loginError.textContent = err.message || 'Login failed. Check your credentials.';
loginError.classList.remove('hidden');
}
});
logoutBtn.addEventListener('click', async () => {
await signOut();
token = '';
dashboardSection.classList.add('hidden');
loginSection.classList.remove('hidden');
});
function showDashboard() {
loginSection.classList.add('hidden');
dashboardSection.classList.remove('hidden');
loadData('members');
}
// --- Tab Switching ---
document.querySelectorAll('.tab-btn').forEach((btn) => {
btn.addEventListener('click', () => {
// Update tab styles
document.querySelectorAll('.tab-btn').forEach((b) => {
b.classList.remove('border-orange-500', 'font-semibold', 'text-orange-600');
b.classList.add('border-transparent', 'text-gray-500');
});
btn.classList.add('border-orange-500', 'font-semibold', 'text-orange-600');
btn.classList.remove('border-transparent', 'text-gray-500');
// Show correct tab content
const tab = (btn as HTMLElement).dataset.tab!;
document.querySelectorAll('.tab-content').forEach((c) => c.classList.add('hidden'));
document.getElementById(`tab-${tab}`)!.classList.remove('hidden');
loadData(tab);
});
});
// --- Data Loading ---
async function loadData(entity: string) {
try {
const res = await fetch(`${API_BASE}/${entity}s`, {
headers: { Authorization: `Bearer ${token}` },
});
const data = await res.json();
renderTable(entity, data);
} catch (err) {
console.error(`Failed to load ${entity}s:`, err);
}
}
function renderTable(entity: string, items: any[]) {
const tbody = document.getElementById(`${entity}s-table`)!;
if (items.length === 0) {
tbody.innerHTML = `<tr><td colspan="6" class="px-4 py-8 text-center text-gray-400">No ${entity}s found. Click "Add" to create one.</td></tr>`;
return;
}
tbody.innerHTML = items.map((item) => {
let cells = '';
if (entity === 'member') {
cells = `
<td class="px-4 py-3">${item.name}</td>
<td class="px-4 py-3">${item.cdmEmail}</td>
<td class="px-4 py-3">${item.studentNumber}</td>
<td class="px-4 py-3">${item.yearLevel}</td>
<td class="px-4 py-3">${item.program}</td>`;
} else if (entity === 'officer') {
cells = `
<td class="px-4 py-3">${item.name}</td>
<td class="px-4 py-3">${item.office}</td>
<td class="px-4 py-3">${item.position}</td>
<td class="px-4 py-3">${item.role}</td>`;
} else {
cells = `
<td class="px-4 py-3">${item.title}</td>
<td class="px-4 py-3">${item.eventType}</td>
<td class="px-4 py-3">${item.date}</td>
<td class="px-4 py-3">${item.venue}</td>`;
}
return `<tr>${cells}
<td class="px-4 py-3">
<button class="edit-btn text-blue-600 hover:underline mr-3" data-id="${item.id}" data-entity="${entity}">Edit</button>
<button class="delete-btn text-red-600 hover:underline" data-id="${item.id}" data-entity="${entity}">Delete</button>
</td>
</tr>`;
}).join('');
// Attach edit and delete handlers
tbody.querySelectorAll('.edit-btn').forEach((btn) => {
btn.addEventListener('click', () => {
const id = (btn as HTMLElement).dataset.id!;
const ent = (btn as HTMLElement).dataset.entity!;
const item = items.find((i) => i.id === id);
openModal(ent, item);
});
});
tbody.querySelectorAll('.delete-btn').forEach((btn) => {
btn.addEventListener('click', async () => {
const id = (btn as HTMLElement).dataset.id!;
const ent = (btn as HTMLElement).dataset.entity!;
if (confirm(`Are you sure you want to delete this ${ent}?`)) {
await deleteItem(ent, id);
loadData(ent);
}
});
});
}
// --- Modal ---
document.querySelectorAll('.add-btn').forEach((btn) => {
btn.addEventListener('click', () => {
const entity = (btn as HTMLElement).dataset.entity!;
openModal(entity, null);
});
});
function openModal(entity: string, item: any | null) {
currentEntity = entity;
editingId = item?.id || null;
modalTitle.textContent = item ? `Edit ${capitalize(entity)}` : `Add ${capitalize(entity)}`;
// Build form fields dynamically
const fields = entityFields[entity];
modalFields.innerHTML = fields.map((f) => {
const value = item ? item[f.name] || '' : '';
return `
<div class="mb-3">
<label class="block text-sm font-medium mb-1" for="field-${f.name}">${f.label}</label>
<input
type="${f.type}"
id="field-${f.name}"
name="${f.name}"
value="${value}"
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-orange-500"
${f.required ? 'required' : ''}
/>
</div>`;
}).join('');
modalOverlay.classList.remove('hidden');
}
function closeModal() {
modalOverlay.classList.add('hidden');
modalFields.innerHTML = '';
editingId = null;
}
modalClose.addEventListener('click', closeModal);
modalCancel.addEventListener('click', closeModal);
modalOverlay.addEventListener('click', (e) => {
if (e.target === modalOverlay) closeModal();
});
modalForm.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(modalForm);
const body: Record<string, string> = {};
formData.forEach((val, key) => { body[key] = val as string; });
try {
if (editingId) {
await updateItem(currentEntity, editingId, body);
} else {
await createItem(currentEntity, body);
}
closeModal();
loadData(currentEntity);
} catch (err) {
console.error('Save failed:', err);
alert('Failed to save. Check the console for details.');
}
});
// --- API Calls ---
async function createItem(entity: string, data: Record<string, string>) {
await fetch(`${API_BASE}/${entity}s`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(data),
});
}
async function updateItem(entity: string, id: string, data: Record<string, string>) {
await fetch(`${API_BASE}/${entity}s/${id}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(data),
});
}
async function deleteItem(entity: string, id: string) {
await fetch(`${API_BASE}/${entity}s/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
});
}
// --- Utility ---
function capitalize(str: string) {
return str.charAt(0).toUpperCase() + str.slice(1);
}
</script>
What does this code do?
The page has three layers. The login section shows a simple email/password form that authenticates via Cognito. Once signed in, the dashboard section appears with tab navigation for Members, Officers, and Events.
The modal overlay is a fixed full-screen backdrop that appears when you click Add or Edit. JavaScript dynamically generates the correct form fields based on which entity type you selected. On submit, it calls your Lambda API and refreshes the table.
- Save src/pages/admin/index.astro.
- Start the dev server by running:
npm run dev
- Open http://localhost:4321/admin/ in your browser. You should see the login form with Email and Password fields.
Page not loading?
If you see a build error in the terminal, check for any typos in the Astro frontmatter (the section between the --- fences). Make sure the Layout import path matches your actual file structure.
Help me debug my admin page build error
💡 Why use modals instead of inline forms?
Modal dialogs keep the data table visible while you fill in the form. The admin sees exactly which record they clicked on, and the overlay prevents accidental clicks on other buttons. Closing the modal (via the X button, Cancel, or clicking outside) discards uncommitted changes without navigating away.
Your admin dashboard is ready. Non-technical successors can now manage members, officers, and events through a clean UI with modal forms. Next up, you will deploy everything to AWS S3.
Deploy to AWS S3
Deploy to AWS S3 and CloudFront
Your website looks great locally. Now it is time to put it on the internet where anyone can visit it.
AWS S3 can host static websites directly from a storage bucket. You will build your Astro project into plain HTML files, upload them to S3, and configure the bucket so the world can access your site via a public URL.
In this step, get ready to:
- Build your Astro site into production-ready static files.
- Create and configure an S3 bucket for public website hosting.
- Upload your site and verify it is live on the internet.
Build the production site
Astro's dev server is great for local development, but S3 needs plain HTML, CSS, and JavaScript files. The build command compiles your Astro components into static files that any web server can serve.
- Stop the dev server by pressing Ctrl+C in your VS Code terminal.
- Build the production version of your site by running this command:
npm run build
What does this command do?
Astro compiles all your .astro components, layout, and data files into plain HTML pages. The output lands in a dist/ folder at the root of your project.
Each page (Home, Events, Officers) becomes its own index.html inside a matching folder. These are the files you will upload to S3.
- Confirm the build succeeded by running:
ls dist
You should see folders like events/ and officers/ alongside an index.html file.
Build failed?
Check the error message in your terminal. The most common cause is a typo in one of your .astro files (an unclosed tag or a missing import). Fix the reported file and run npm run build again.
Help me debug my Astro build error
Create the S3 bucket
An S3 bucket is a container that stores your website files in the cloud. You need to create one and configure it to allow public access, since website visitors are anonymous users.
- Open the AWS Management Console in your browser at https://console.aws.amazon.com.
- Search for S3 in the top search bar and select S3 from the results.
- Click Create bucket.
- Enter a bucket name. Use something like asbg-cdm-website but note that bucket names must be globally unique across all of AWS, lowercase, and contain no spaces.
- For AWS Region, select ap-southeast-1 (Asia Pacific - Singapore).
- Scroll down to Block Public Access settings for this bucket.
- Uncheck the Block all public access checkbox.
- A warning appears. Check the acknowledgment box that says I acknowledge that the current settings might result in this bucket and the objects within becoming public.
- Leave all other settings as default and click Create bucket at the bottom of the page.
Why unblock public access?
By default, S3 blocks all public access to protect sensitive data. Since your website needs to be visible to anyone on the internet, you must explicitly allow public reads. The bucket policy you set next controls exactly what is public.
You should see your new bucket listed on the S3 buckets page.
Bucket name already exists?
S3 bucket names are globally unique. If your chosen name is taken, try adding a short suffix like your initials or a number (e.g., asbg-cdm-website-2026). Keep it lowercase with only hyphens as separators.
Help me pick a unique S3 bucket name
Configure static website hosting and bucket policy
Creating the bucket is only the first half. You also need to tell S3 to serve files as a website and add a policy that grants read access to visitors.
- Click your bucket name to open it.
- Select the Properties tab.
- Scroll all the way down to Static website hosting and click Edit.
- Select Enable under Static website hosting.
- Set Index document to index.html.
- Set Error document to 404.html.
- Click Save changes.
After saving, you will see a Bucket website endpoint URL at the bottom of the Static website hosting section. Copy this URL. You will use it to visit your live site soon.
What is the error document for?
When a visitor navigates to a page that does not exist, S3 serves the 404.html file instead of a generic error. You will create this file in the Secret Mission later.
- Now select the Permissions tab.
- Scroll down to Bucket policy and click Edit.
- Paste the following policy into the editor, replacing the bucket name with your own:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "PublicReadGetObject",
"Effect": "Allow",
"Principal": "*",
"Action": ["s3:GetObject"],
"Resource": ["arn:aws:s3:::[[BUCKET_NAME=\"asbg-cdm-website\"]]/*"]
}
]
}
What does this policy do?
This bucket policy grants anyone ("Principal": "*") permission to read objects (s3:GetObject) from your bucket. The /* at the end of the Resource means every file in the bucket is readable.
Without this policy, visitors would get an Access Denied error even though you unblocked public access at the bucket level. Both settings work together.
- Click Save changes.
You should see a red Publicly accessible badge appear on your bucket. This confirms visitors can read your website files.
Getting a policy error?
Make sure you replaced the bucket name in the Resource line with your actual bucket name. The ARN format must be exactly arn:aws:s3:::your-bucket-name/* with three colons before the bucket name and /* at the end.
Help me fix my S3 bucket policy error
Upload your site and verify it is live
The bucket is ready. Now you will use the AWS CLI to upload the contents of your dist/ folder into the bucket.
- Return to your VS Code terminal and upload your built site by running this command:
aws s3 sync dist/ s3://[[BUCKET_NAME="asbg-cdm-website"]] --delete
What does this command do?
The aws s3 sync command compares your local dist/ folder with the contents of your S3 bucket. It uploads new or changed files and the --delete flag removes anything from the bucket that no longer exists locally.
This makes future deployments fast. Only changed files get uploaded instead of the entire site.
You should see output listing each file being uploaded (e.g., upload: dist/index.html to s3://...).
Access Denied or invalid credentials?
Run aws configure to check that your Access Key ID and region are set correctly. Make sure the IAM user has S3 permissions. Also confirm you typed your bucket name correctly in the command.
Help me fix my AWS CLI credentials for S3 upload
- Open the Bucket website endpoint URL you copied earlier in your browser.
Where do I find the endpoint URL?
Go back to your bucket in the S3 console, click the Properties tab, and scroll to Static website hosting. The URL looks like http://your-bucket-name.s3-website-ap-southeast-1.amazonaws.com.
Your website is now live on the internet. You should see the same Home page you saw locally, with the hero section and navigation bar.
- Click the Events link in the navigation bar.
- Click the Officers link.
- Verify that all three pages load correctly and the navigation works between them.
Seeing a 403 Forbidden error?
This means the bucket policy is not applied correctly. Go back to the Permissions tab and confirm the Bucket policy section shows your policy. Also check that Block all public access is fully unchecked under Block public access (bucket settings).
Help me fix my S3 website 403 Forbidden error
Your website is live and accessible via the S3 endpoint. Next up, you will connect your custom domain awssbg-cdm.live with an SSL certificate so visitors can reach your site at a clean, professional URL.
Connect Your Custom Domain
Your website is live on S3, but visitors have to use a long AWS endpoint URL to reach it. A professional site needs a clean domain name and HTTPS security.
In this step, you will generate an SSL certificate signing request, activate your SSL on name.com, create a CloudFront distribution to serve the site over HTTPS, and point www.awssbg-cdm.live to CloudFront so visitors reach your site at a professional URL with the padlock icon.
In this step, get ready to:
- Generate a CSR and private key for your domain.
- Activate your SSL certificate on name.com.
- Import the certificate into AWS Certificate Manager and create a CloudFront distribution.
Generate the CSR and private key
A Certificate Signing Request (CSR) is a block of encoded text that contains your domain name and organization details. You submit it to a Certificate Authority (in this case, name.com) to prove you are requesting a certificate for a domain you own.
- Open your VS Code terminal.
- Generate a private key and CSR by running this command:
openssl req -new -newkey rsa:2048 -nodes -keyout www.awssbg-cdm.live.key -out www.awssbg-cdm.live.csr
What does this command do?
This generates two files. The .key file is your private key (keep this safe and never share it). The .csr file is the certificate signing request that you will paste into name.com.
The -nodes flag means the private key will not be encrypted with a passphrase, which is required for importing into AWS later.
OpenSSL will ask you several questions. Fill them in as follows:
- Country Name: enter PH.
- State or Province: enter your province (e.g. Metro Manila).
- Locality Name: enter your city.
- Organization Name: enter AWS Student Builders Group CDM.
- Organizational Unit: press Enter to leave blank.
- Common Name: enter www.awssbg-cdm.live (this must match your domain exactly).
- Email Address: press Enter to leave blank.
- Challenge password: press Enter to leave blank.
- Optional company name: press Enter to leave blank.
- Confirm both files were created by running:
ls *.key *.csr
You should see www.awssbg-cdm.live.key and www.awssbg-cdm.live.csr in the output.
- Display the CSR content so you can copy it by running:
cat www.awssbg-cdm.live.csr
Copy the entire output including the -----BEGIN CERTIFICATE REQUEST----- and -----END CERTIFICATE REQUEST----- lines. You will paste this into name.com next.
OpenSSL not found?
On Windows, OpenSSL may not be installed by default. You can use Git Bash (installed with Git for Windows) which includes OpenSSL, or install it via winget install ShiningLight.OpenSSL. On macOS and Linux, OpenSSL is pre-installed.
Help me install OpenSSL on my machine
Activate the SSL certificate on name.com
Now you will paste the CSR into name.com to activate your SSL certificate and complete domain validation.
- Log in to your name.com account at https://www.name.com.
- Click My Domains in the top navigation.
- Click on awssbg-cdm.live to open the domain management page.
- Find the SSL Certificates section and click on your certificate to manage it.
- Click Activate or Set Up SSL (the exact label depends on your certificate status).
- In the CSR field, paste the entire CSR you copied from your terminal (including the BEGIN and END lines).
- For Server Type, select Other or Apache (either works for importing into AWS).
- Submit the CSR. Name.com will now validate your domain ownership.
How does domain validation work?
Name.com typically sends a validation email to the domain's admin contacts or asks you to add a DNS record. Follow the instructions they provide. Once validation completes, your certificate will be issued and available for download.
Validation usually takes a few minutes, but can take up to 24 hours depending on the method.
Import the certificate into ACM and set up CloudFront
Once name.com issues your certificate, you will download the certificate files and import them into AWS Certificate Manager. CloudFront requires the certificate to be in the us-east-1 (N. Virginia) region.
- Download the certificate files from name.com. You should receive your domain certificate (.crt file) and the CA bundle (intermediate certificates).
- In the AWS Console, switch your region to US East (N. Virginia) using the region selector in the top right.
- Search for Certificate Manager in the top search bar and open AWS Certificate Manager.
- Click Import a certificate.
- In Certificate body, paste the contents of your .crt file (your domain certificate).
- In Certificate private key, paste the contents of your www.awssbg-cdm.live.key file.
- In Certificate chain, paste the CA bundle (intermediate certificates from name.com).
- Click Import.
How do I get the private key contents?
Run cat www.awssbg-cdm.live.key in your terminal to display the private key. Copy the entire output including the -----BEGIN PRIVATE KEY----- and -----END PRIVATE KEY----- lines.
You should see your imported certificate listed with status Issued in the ACM console.
- Now search for CloudFront in the top search bar and open it.
- Click Create distribution.
- In the Origin domain field, enter your S3 website endpoint (do NOT select the bucket from the dropdown):
[[S3_WEBSITE_ENDPOINT="asbg-cdm-website.s3-website-ap-southeast-1.amazonaws.com"]]
Use the website endpoint, not the bucket name
You must type the S3 website endpoint URL (ending in .s3-website-ap-southeast-1.amazonaws.com). Do not select the bucket name from the dropdown. The dropdown creates a REST API origin that breaks S3's index document routing.
- Scroll down to the Settings section.
- In Alternate domain name (CNAME), click Add item and enter www.awssbg-cdm.live.
- In Custom SSL certificate, select the certificate you just imported from the dropdown.
- In Default root object, type index.html.
- Leave all other settings at their defaults.
- Click Create distribution at the bottom.
The distribution will show status Deploying. Wait 5-10 minutes until it changes to Enabled. Copy the Distribution domain name value (e.g. dXXXXXXXXXX.cloudfront.net).
- Go back to name.com and open the DNS management for awssbg-cdm.live.
- Add a CNAME record with Name set to www and Value set to your CloudFront distribution domain name (e.g. dXXXXXXXXXX.cloudfront.net).
- Save the DNS record.
How long does DNS take to propagate?
DNS changes typically take 5-30 minutes to propagate, but can take up to 48 hours in some cases. If your domain does not resolve immediately, wait and try again.
- Open https://www.awssbg-cdm.live in your browser.
- Verify the padlock icon appears in the address bar and your site loads correctly.
- Click through to the Events and Officers pages to confirm all navigation works.
Site not loading on your custom domain?
If you see a DNS error, the CNAME record may not have propagated yet. Wait 15 minutes and try again. If you see a CloudFront error, make sure the distribution has finished deploying (status Enabled).
If you see a certificate error, confirm that the Alternate domain name in your CloudFront distribution matches exactly www.awssbg-cdm.live and that you selected the correct certificate.
Help me troubleshoot my custom domain setup
Secret mission
Add a Custom 404 Error Page
What happens when someone visits a page that does not exist on your site? Right now they see a generic AWS error. Build a branded 404 page that keeps visitors on your site and guides them back to the home page.
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. The S3 bucket costs fractions of a cent per month for storage and requests, covered by your AWS credits.
Cost warning
Your S3 bucket incurs a tiny charge for storage and requests (fractions of a cent per month). If you completed the Secret Mission, your CloudFront distribution's free tier includes 1 TB of data transfer per month. Neither resource will meaningfully impact your AWS credits, but deleting them when you are done is good practice.
Resources you used:
- S3 bucket (asbg-cdm-website) with static website hosting enabled.
- CloudFront distribution (if you completed the Secret Mission).
- Local asbg-website project folder on your Desktop.
Keep everything running
No action needed. Choose this if you are still actively building or using the website as your org's live site.
- Your S3 bucket stays live at its website endpoint URL.
- Your CloudFront distribution (if created) continues serving HTTPS traffic.
- Your local asbg-website folder remains on your Desktop for future edits.
Pause - I'll come back to this later
Shut down local processes to free up memory. Your AWS resources stay intact but cost almost nothing while idle.
- Stop the Astro dev server if it is still running by pressing Ctrl+C in the terminal.
- Your S3 bucket and CloudFront distribution remain live. No further action is needed for them.
Delete - I don't want to use this again
Remove all project resources and start fresh. This cannot be undone.
If you completed the Secret Mission, delete the CloudFront distribution first:
- Open the CloudFront console at https://console.aws.amazon.com/cloudfront/v4/home.
- Select the checkbox for your distribution.
- Click Disable, then confirm by clicking Yes, Disable.
- Wait until a new timestamp appears in the Last modified column. This may take a few minutes.
- Select the checkbox for the distribution again.
- Click Delete, then confirm by clicking Delete.
Delete the S3 bucket:
- Delete the bucket and all its contents by running this command:
aws s3 rb s3://asbg-cdm-website --force
This empties the bucket and then removes it in a single step.
Delete the local project folder:
- Remove the project folder from your Desktop by running:
rm -rf ~/Desktop/asbg-website
On Windows, run this instead:
Remove-Item -Recurse -Force ~\Desktop\asbg-website
That's a Wrap!
That's a Wrap!
Nice work! You just built a responsive, multi-page organization website from scratch and deployed it live on the internet using AWS infrastructure.
You've learned how to:
- Build a multi-page responsive website using Astro and Tailwind CSS with reusable layout components and data-driven pages.
- Deploy a static site to AWS S3 with proper bucket configuration, public access policies, and static website hosting.
- Upload and sync production builds using the AWS CLI and access the live site via a public internet URL.
- Secret Mission: Added CloudFront for HTTPS and global CDN caching, learning about edge locations and content delivery.
Ready to quiz yourself?