← Back to blog

How This Site Is Built

A static site with a headless CMS backend, an auto-rebuild pipeline, and no hosting fees beyond a single VPS.

Most personal sites are either fully static with no CMS, or they run a database-backed monolith like WordPress. This site takes a middle path: a headless CMS for content management and a fully static frontend that gets rebuilt automatically whenever something is published.


Diagram of the site's deploy pipeline: Strapi and Gitea feed a manually-triggered Ansible Configure workflow that updates the internal staging environment; once verified, a separate Publish to Production workflow builds in isolation, checks for leaked internal URLs, and ships to the public VPS at bschaefer.net, served over TLS via Let's Encrypt, which Cloudflare DNS (nameservers only) points visitors at directly.

The full path from a content edit or code push to a live visitor request.


The Stack

The backend is Strapi v5, an open-source headless CMS running on a self-hosted VPS managed by PM2. All content — blog posts, projects, resume data — lives in Strapi and is exposed via a REST API. The frontend is Next.js 15 with output: 'export', which means the entire site is pre-rendered to plain HTML, CSS, and JavaScript at build time. There is no Node.js server running in production. Nginx serves the static files directly.


The Deploy Pipeline

An earlier version of this pipeline used a webhook that rebuilt the frontend and shipped it to production automatically within seconds of hitting Publish in Strapi. That's been retired in favor of an explicit, two-stage process — a little less instant, but it removes a real failure mode: a build that accidentally pointed at internal-only settings could previously reach production with nobody reviewing it first.

  1. A manually-triggered CI workflow refreshes the backend and the staging environment with the latest code, then the frontend is rebuilt and checked on a staging subdomain.
  2. A second, separately-triggered workflow does an isolated production build, scans the output for anything that would leak an internal-only hostname, and only then ships it to the public VPS.

Nothing reaches the public site automatically — every release is a deliberate, two-step action, trading a few minutes of hands-on time for the guarantee that an internal URL can never quietly end up in front of real visitors.


Why Static Export

A static site has no attack surface beyond what Nginx exposes, scales trivially, and survives traffic spikes without breaking a sweat. The tradeoff is that content changes require a rebuild — acceptable for a personal site where real-time updates aren't needed.


DNS

DNS for the domain is hosted on Cloudflare, used purely for nameserver hosting — there's no proxying or CDN caching sitting in front of the origin. Requests go straight to the VPS, so Nginx sees real visitor IPs and there's no cache to purge after a deploy.

Source

The full source is on Gitea hosted internally.