Modernizing My Static Site Stack in 2026

bertilak.ca has been running a neglected WordPress install behind Apache for years. The landing page was literally a black page with a 170KB Fraktur logo PNG as a background image. No links, no content, nothing. The blog had 36 migrated posts sitting in Pelican locally but never deployed because the pipeline didn't exist.

So a couple weeks ago I decided to fix all of it.

What I Killed

WordPress. The imaginarium directory was a full WordPress install — wp-admin, wp-includes, plugins, themes, the works — sitting behind Apache2 on the VPS. Traefik handled TLS termination and proxied to Apache on port 8081. It worked, barely, but every time I thought about writing a post I'd remember the stack and just not bother.

Apache2. Nothing wrong with Apache conceptually, but the rest of my infrastructure (Traefik, matrix-synapse, postgres, coturn) is all Docker containers on the same VPS. Apache was the odd one out running directly on the host. Replaced it with nginx:alpine in Docker, consistent with everything else.

Bootstrap 3. The Pelican theme was a fork of fh5co-marble — a Bootstrap 3 theme from 2015. It loaded jQuery, Bootstrap JS, Flexslider, Waypoints, Animate.css, Modernizr, glyphicon fonts, icomoon icons, and PHPMailer (for a contact form I've never used). Seven CSS files. 13,506 lines of CSS. Something like 10 JavaScript files loaded on every page.

The Logo. 1600x1600 PNG Fraktur "b" created in Inkscape. It looked like a medieval manuscript initial. Fine if you're running a fantasy RPG blog. Less fine for an environmental technician's professional site. Replaced it with nothing — just clean Work Sans typography in the brand green.

What I Built

Landing page. Black background, a light-weight "b" in Work Sans at 140px in the brand green (#224b12), my name, a one-line tagline, and contact links. That's it. It loads in one HTTP request. The favicon is an inline SVG. Total payload is about 5KB.

Custom Pelican theme. Tore out every framework dependency and rebuilt from scratch. The new theme.css is 600 lines — CSS custom properties, Grid layout for the content area, Flexbox for the header, IntersectionObserver for the card fade-in animations. No JavaScript frameworks. The mobile nav toggle is 15 lines of vanilla JS. The whole theme directory is about 25KB total, down from roughly 3MB of vendor crap.

The cards float in as you scroll, same visual effect the old fh5co-marble demo had, but implemented with about 20 lines of CSS and a 10-line IntersectionObserver instead of jQuery + Waypoints + Animate.css. Same look, none of the weight.

Stork search. Replaced the deprecated tipue_search with Stork. Generates a WASM-based search index at build time. The search feels almost instant because it's running client-side on a pre-built index, no backend.

Gitea CI pipeline. The blog repo lives at git.mosverde.ca. Push to master, a webhook on the VPS catches it, runs uv run pelican content -o output/imaginarium, and rsyncs the output to the nginx docroot. There's also a cron job that checks for new commits every five minutes as a fallback. Deploy takes about 45 seconds from push to live.

nginx in Docker. Docker compose, mounted config and docroot as volumes, Traefik still routes bertilak.ca to port 8081. Zero config changes needed on the Traefik side — just swapped Apache for nginx and it worked.

The Actual Stack Now

Internet  Traefik (Docker, TLS termination)
           nginx:alpine (Docker, port 8081)
           /var/www/bertilak.ca/
               ├── index.html          (static landing)
               ├── style.css           (2.9KB)
               └── imaginarium/        (Pelican static output)

Build pipeline: git push → Gitea → webhook → uv run pelican → rsync

The whole thing is static files served by nginx. No PHP, no database, no WordPress, no Apache, no Bootstrap, no jQuery. If Traefik or nginx goes down, a docker compose up -d brings it back. The site loads faster than my Home Assistant dashboard, which isn't saying much, but still.

What I'd Do Differently

Fix the image migration before rebuilding the theme. The old WordPress posts had {static} references everywhere — about 38 broken image links across 5 articles. I didn't notice until the theme was deployed and someone pointed out a 404. A sed one-liner fixed it, but it would've been smarter to audit the content before getting distracted by CSS.

Use a separate repo for the landing page. Right now the landing page files live outside the blog's git repo. The deploy script copies them from the existing deploy directory into the build output, which means they don't get version-controlled through the pipeline. It works, but it's fragile. At some point I'll probably split it into its own Gitea repo with its own deploy.

Skip the webhook and just use cron. The webhook listener is a 40-line Python HTTP server on port 8082. It works fine, but the cron job would've been enough. Instant deploys feel cool but I'm not exactly posting breaking news here.

Anyway, the site's alive, the blog works, and writing a new post is a git push away from anywhere I have Tailscale and a text editor. Good enough for 2026.

Comments