Student Name: Harrison Fry
Course: CIS 260
Instructor: MD Ali
Semester: Fall 2026
Project Status: In Progress
This project is my personal website: a single-page portfolio that introduces me, my education, and my work experience, and gives visitors a way to contact me. It is built as a static site with Astro and Tailwind CSS, packaged into a Docker image served by nginx, and self-hosted on my own VPS. Every push to main is automatically built and deployed by a GitHub Actions runner on that VPS, and the site is published to the internet securely through a Cloudflare Tunnel.
I chose this project to take the opportunity to learn about several technologies I had little to no experience in. Mainly web development, Git, and CI/CD pipelines.
The intended users are potential employers, colleagues, and anyone who wants to learn about my background or get in touch with me.
- Create a professional online presence at
harrisonafry.com - Build a fast, responsive, accessible website that works on desktop and mobile
- Present my education, experience, and contact information in one place
- Host the site with Docker on cloud infrastructure
- Automate deployment so that pushing to GitHub updates the live site
- Hero header with profile photo, name, and role
- About Me, Education, Experience, and Contact sections
- Light and dark themes
- Responsive, mobile-friendly layout
- Optimized profile image (resized and converted to WebP at build time)
- Custom 404 page
- SEO and link-preview metadata (description, canonical URL, Open Graph tags)
- Automated build and deployment on every push to
main, with a health check that fails the deploy if the site doesn't come up
| Technology | Purpose |
|---|---|
| Astro 7 | Static site framework; builds the site into plain HTML/CSS/JS |
| Tailwind CSS 4 | Styling and responsive layout |
| TypeScript | Site content/config (consts.ts) and the theme script |
| Node.js 24 + pnpm | Installing dependencies and running the build |
| Docker (multi-stage build) | Builds the site and packages it into a small nginx image |
| Docker Compose | Defines and runs the website container |
| nginx | Web server inside the container that serves the static files |
| GitHub | Version control and source hosting |
| GitHub Actions (self-hosted runner) | Builds and deploys the site on the VPS on every push |
Cloudflare Tunnel (cloudflared) |
Publishes the site to the internet over HTTPS without opening ports on the VPS |
| VPS (Linux) | Server that hosts the runner and the website container |
Visitor's browser
| HTTPS
v
Cloudflare (DNS + HTTPS for harrisonafry.com)
| Cloudflare Tunnel (outbound connection from the VPS)
v
VPS
└── Docker network: website-network
├── cloudflared container (tunnel endpoint)
| | HTTP :80
| v
└── personal-website container (nginx serving the built site)
The website container does not publish any ports on the VPS. cloudflared reaches it by name (http://personal-website:80) over the shared website-network Docker network.
git push to main
|
v
GitHub ── triggers the "Deploy" workflow (.github/workflows/deploy.yml)
|
v
Self-hosted GitHub Actions runner on the VPS
1. Checks out the repository
2. Creates website-network if it doesn't exist
3. docker compose up -d --build
Dockerfile stage 1 (node:24-alpine): pnpm install + astro build -> dist/
Dockerfile stage 2 (nginx:alpine): copy dist/ + nginx.conf
4. Waits for the container's health check to report "healthy"
5. Removes old, unused images
Pushes that only change README.md do not trigger a deploy.
- Node.js 24 or newer (includes Corepack, which provides pnpm)
- Git
- Docker with Docker Compose (only needed to run the production container)
git clone https://github.com/harrisonfry/personal-website.gitcd personal-websitecorepack enable
pnpm installLive Project:
https://harrisonafry.com
pnpm devThen open http://localhost:4321 in a web browser.
pnpm build
pnpm previewpnpm build writes the finished static site to dist/, and pnpm preview serves that folder locally for a final check.
docker network create website-network
docker compose up -d --buildThe first command only needs to be run once. The container has no published ports; on the VPS it is reached through the Cloudflare Tunnel, so use pnpm preview to view the production build on your own machine.
- Go to https://harrisonafry.com.
- Scroll through the About Me, Education, and Experience sections.
- Use the sun/moon button in the top-right corner to switch between light and dark themes.
- Use Contact Me to send an email, or the GitHub button to view my GitHub profile.
- Edit the text in
src/consts.ts(name, role, email, education, experience) or the section components insrc/components/. - Preview the change locally with
pnpm dev. - Commit and push to
main. The GitHub Actions workflow rebuilds and redeploys the site automatically. - Check the Actions tab on GitHub to confirm the deploy succeeded.
Screenshots of the live site at https://harrisonafry.com.
personal-website/
├── .github/workflows/
│ └── deploy.yml # CI/CD: build and deploy on the VPS on push to main
├── images/ # README screenshots (not part of the website)
│ ├── homepage-light.png
│ ├── homepage-dark.png
│ └── mobile.png
├── public/
│ ├── favicon.svg
│ └── robots.txt
├── src/
│ ├── assets/
│ │ └── profile-picture.jpg
│ ├── components/ # Page sections (Hero, About, Education, Experience,
│ │ # Contact) and shared pieces (Section, Icon,
│ │ # SkillPill, ThemeToggle)
│ ├── layouts/
│ │ └── Layout.astro # Shared <head>, metadata, theme toggle, footer
│ ├── pages/
│ │ ├── index.astro # Home page; assembles the sections
│ │ └── 404.astro # Not-found page
│ ├── scripts/
│ │ └── theme.ts # Light/dark theme logic, inlined into <head>
│ ├── styles/
│ │ └── global.css # Tailwind setup and theme colors
│ └── consts.ts # Site content: name, email, education, experience
├── astro.config.mjs # Astro configuration (static output, Tailwind plugin)
├── Dockerfile # Two-stage build: Node builds the site, nginx serves it
├── nginx.conf # nginx config used inside the container
├── docker-compose.yml # Runs the website container on website-network
├── .dockerignore # Keeps node_modules, .git, .env, etc. out of the image
├── package.json
├── pnpm-lock.yaml
└── README.md
src/consts.ts is the single place to edit the site's text content; the components read from it.
| Date | Milestone |
|---|---|
| Sep 11 | Created the repository and initial project |
| Sep 12 | Chose Node.js with a static site framework (Astro); added the first Dockerfile |
| Sep 19 | Worked on the Docker image and the GitHub Actions workflow; began using AI tools (Claude and ChatGPT) |
| Sep 25 | Added a Docker Compose configuration for the website and Cloudflare Tunnel |
| Oct 2 | Updated the website and About Me section; fixed the deployment pipeline so it builds and deploys the correct image; stopped README-only changes from triggering a deploy |
| Oct 3 | Wrote project documentation (this README) |
Every deploy runs a health check. The Docker image defines a HEALTHCHECK that requests the home page from nginx inside the container, and the GitHub Actions workflow waits up to 120 seconds for the container to report healthy. If it doesn't, the workflow fails and prints the container's logs.
| Test | Expected Result | Actual Result | Status |
|---|---|---|---|
Push to main deploys the site |
Workflow completes and container reports healthy | Workflow completed and site updated | Pass |
Push that only changes README.md |
No deploy is triggered | README only changes do not trigger wordlfow | Pass |
| Homepage loads at harrisonafry.com | All sections display correctly | Url loads correctly and all elements are displayed | Pass |
| Theme toggle | Switches light/dark; choice persists after reload | Theme toggles correctly | Pass |
| GitHub button | Opens my GitHub profile in a new tab | Link opens correctly | Pass |
Unknown URL (e.g. /does-not-exist) |
Custom 404 page is shown | Unknown pages display the 404 page | Pass |
| Browser testing | Site works in Chrome, Firefox, Safari, and Edge | Site works on Desktop and Mobile Firefox and Chrome | Pass |
Every commit, even ones only changing the REAMDME, was triggering the GitHub workflow and building a new docker container and deploying it.
I added a limit to the GitHub workflow to ignore any changes to the README so that I wasn't triggering an update unnecessarily.
I need to keep the scope of the workflow constrained to only files that affect the site.
- No open inbound ports. The site is published through a Cloudflare Tunnel, which makes an outbound connection from the VPS. The website container doesn't publish any ports.
- HTTPS. Visitors connect over HTTPS, terminated by Cloudflare.
- Small attack surface. The site is fully static: there is no database, no login, no server-side code, and no forms that accept user input.
- Minimal runtime image. The final image contains only nginx and the built files; Node.js, source code, and dependencies stay in the build stage.
- Secrets kept out of the repository. The Cloudflare Tunnel token is provided as an environment variable,
.envfiles are ignored by both.gitignoreand.dockerignore, and nothing secret is committed.
- The site runs on a single VPS, so it goes offline if that server does.
- There are no automatic tests so all testing is manual.
- Add a Projects section showcasing homelab
- Tag each image with its Git commit so a previous version can be redeployed quickly
- Add automated tests
New skills learned:
- Node.js
- Astro
- Git workflows
- Docker compose build
Astro Documentation
https://docs.astro.build/
Tailwind CSS Documentation
https://tailwindcss.com/docs
Docker Compose File Reference
https://docs.docker.com/reference/compose-file/
GitHub Actions Documentation (workflows, self-hosted runners)
https://docs.github.com/actions
Cloudflare Tunnel Documentation
https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/
AI Tool(s):
- Claude (Claude Code)
How AI was used:
- Claude Reviewed my code and helped me understand Node.js and the Astro framework as I had never used either before. It also helped me run tests and troubleshoot errors I encountered.
This project was initially rather daunting because almost every technology used was one I had little to no experience in. I knew about Node.js and that it was a pretty standard backend for web development but I had never used it or the Astro framework before. I had a little experience with cloud infrastructure for classes but never with Oracle's cloud services.
The most difficult part was most definitely learning how to use Node.js. It is something I haven't had to learn in any of my classes and the last time I did anything with writing websites, it was 2009 and I was writing in pure HTML. This section also took me the longest and I spent most of the time reading documentation rather than writing code. The most enjoyable part was the CI/CD pipeline. It was so satisfying to see the all the moving parts work together. Being able to make a change to the code and commit it, then watch as it builds, deploys remotely, and see the changes go live was amazing.
Name: Harrison Fry
GitHub: https://github.com/harrisonfry
Website: https://harrisonafry.com
This project was created for educational purposes as part of CIS 260.
Unless otherwise stated, all original work was created by the student.


