Self-Hosting my Website
Overview
My personal website rahul.ws has a lot of moving parts. The source code for the website is public on github at rvaidun/briOS. I forked the site from brianlovin/briOS because I liked the design.
By far the biggest moving part is rahul.ws/listening page which shows what I am currently listening to. It also acts as my mini Spotify wrapped showing top artists and top tracks for a given time period. There is also a some git style chart that show my most active listening times during the week is and there is also a Grafana style date range picker so you can find top songs this week, last 3 months, YTD, etc. I get the data by periodically calling Spotify’s API to get the most recent tracks I’ve been listening to and add the new tracks to a Postgres Database. I also have some one time backfill scripts to backfill the historical listening data I get from requesting my data from Spotify.
rahul.ws/photos is a collection of photos I want to share. How it works is i have a public google photos album. As I add photos to the album a cron job will pick up the new photos every 24 hours and write them to Cloudflare R2, an S3 compatible object storage. I have the DNS for the S3 storage on media.rahulvaidun.com and the raw images are hosted from there. In Next.js I do some image optimizations such as resizing and compressing the images before serving them to the client.
rahul.ws/places is a list of my favorite restaurants I’ve tried. It works similar to spotify listening by periodically polling a google maps and adding the new places to a notion database. I primarily used notion db because it was the default db when I forked briOS. I would not have thought to use Notion as a DB but I’ve grown to enjoy it as sometimes I have to manually fill in some details like the City name and notion has a nice UI for these types of edits. Also the size of the DB is relatively small so notion handles it pretty well.
rahul.ws/blog is just a blog that I maintain on notion. BriOS the next JS website directly talks to notion to retrieve the data and acts as a pure html wrapper that lets me style the blog to my liking.
rahul.ws/guestbook is a signature wall. Users draw something on an HTML canvas, type a name and an optional note, and hit submit. The drawing gets serialized to an SVG on the client, POST'd to my API, and stored as a row in Postgres alongside the name and note. On the page each entry renders as a little polaroid card scattered across a desk mat, and you can drag them around or heart the ones you like.
Upstash Redis also powers the little heart buttons scattered all over the site (blog posts, individual photos in the lightbox, guestbook entries). Every hearteable thing has a slug, and each click just fires an atomic INCR against hearts:<slug>; on page load I batch-read whatever counts I need with a single MGET. Postgres never sees any of it, which keeps hearts fast and lets the counters survive independently of whatever I'm doing to the database.
I also have an admin page on rahul.ws which I can use to manually trigger cron jobs. The admin UI shows a"Run now" button which calls crons.rahulvaidun.com on the droplet forwards to a small bearer-token-gated receiver on the mini PC that shells out to docker compose run cron-<job>.
Architecture Problems
The initial app used to run on Vercel. The Next.js app hosted there, and the database was on Neon (Vercel's managed Postgres partner). Vercel handled the CDN, the image optimizer, and its own CI. One git push and the site was live a couple minutes later.
The one thing that never lived on Vercel was the cron jobs. I already had a DigitalOcean droplet running Caddy in front of a few small side projects. so the Spotify/Google Photos/Notion sync jobs ran there as Docker containers. Their Dockerfiles lived in a separate server-config repo and I built them by hand ssh droplet && docker compose build && docker compose up -d and a git clone at build time to pull briOS source into the image. No CI, no image registry. When I changed a script I SSH'd in and rebuilt.
I liked this primarily was super low effort to deploy push any changes. I could just edit the source push and vercel would handle everything in terms of pushing. I also maintained nearly nothing because vercel and neon were handling it for me
The cons were 1/reads happened on Vercel against Neon over the public internet, writes happened from the droplet against the same Neon over the public internet. The source code for the crons were in briOS so updating the cron jobs whenever the source got updated was a pretty manual and tedious process. See
More annoying than the split setup was the cost. I was quickly running out of vercel free tier and upgrading was going to cost me $20/month. I also ran out of Neon and they paused my DB and upgrading that was going to cost $20/month. Even though this was super convenient running it was going to cost me at least $40/month so i had to switch to an alternative
After
I moved everything on one mini PC at home. briOS runs as a Docker container, Postgres runs as a sibling container on the same host, and the three cron images live there too. docker compose on the mini PC now owns the whole stack.
The public entrypoint stayed the same with Cloudflare but instead of going to vercel and now it goes to the droplet. the droplet now reverse-proxies over Tailscale into the mini PC to get Next Server
The CI/CD also got a lot more involved now that Vercel did not handle everything for me. I wrote a GitHub Actions workflow now builds all four images (briOS + three crons) with path filters so only the ones whose source actually changed rebuild, pushes them to GHCR, then SSHs through the droplet into the mini PC to pull and restart. The whole pipeline takes about five minutes on a typical push and I never touch the droplet or the mini PC directly. Reads and writes are now container-to-container on the same box instead of two round-trips across the public internet to Neon. The image optimizer runs on my own hardware and I don’t need to pay vercel. Total monthly cost dropped from Vercel Pro + Neon to just the droplet which I was anyways already paying for.
The biggest con of this is it became a lot more intensive and reliability became my problem to deal with. an internet outage at my home means my website is down. Also the network latency is much larger with it going from Cloudflare → Droplet → Tailnet → Minipc. Uncached it takes around 2 seconds to download an image which is absurd.
The site is not fullly self hosted with R2 and Upstash Redis still in play but I haven’t hit any cost concerns with this yet so it’s fine for now 🙂