Back to blog

How I Host This Website

A deep dive into the architecture behind sandover.ca; from OpenBSD firewall rules to mutual TLS (mTLS) between a VPS and my home server.

Post

Introduction

Most personal sites are a VPS running nginx with a Let's Encrypt cert slapped on top. That works fine, but where's the fun in that? This post walks through the full architecture behind sandover.ca. Why I made each decision, and what I learned along the way.

The Architecture

At a high level, traffic flows like this:

Internet → pf firewall → Caddy → WireGuard tunnel → Debian VM → nginx

The VPS is the public-facing endpoint. It runs OpenBSD, for reasons I've already written about. The actual web content lives on a Debian VM on my home network, served by nginx. The two are connected via a WireGuard tunnel, and the VPS never exposes my home IP to the internet.

The VPS

pf firewall

The first line of defence is OpenBSD's pf. The ruleset starts with block drop all - default deny, everything blocked unless explicitly permitted. From there I open only what's needed: UDP port for WireGuard, TCP 80 and 443 for web traffic, and nothing else inbound. Outbound is equally tight, only HTTP/S, DNS, NTP, and WireGuard keepalives are permitted.

SSH is locked behind the WireGuard tunnel and only accessible from VPN peers on a non-standard port with public key authentication only. No password auth, no public SSH port.

Caddy

Caddy handles TLS termination and reverse proxying. I chose Caddy for its automatic certificate management, clean configuration syntax, and sane defaults. Security headers are set at the Caddy layer so they apply globally. CSP, HSTS with a long max-age, Permissions-Policy, X-Frame-Options, and X-Content-Type-Options.

Because OpenBSD restricts binding to ports below 1024 for unprivileged users, pf redirects incoming port 80 and 443 to 8080 and 8443 where Caddy listens. Caddy is bound to localhost only.

The WireGuard Tunnel

Traffic between the VPS and my home VM travels over a WireGuard tunnel on a non-standard UDP port. WireGuard is configured using OpenBSD's native hostname.if format. VPN peers are isolated from each other at the pf level, and SSH access through the tunnel is scoped to the gateway IP only.

The Home Server

Mutual TLS (mTLS)

Traffic arriving from Caddy isn't just plain HTTP, that would be boring, and if the WireGuard tunnel is ever compromised - bad actors could see everything. It's encrypted with mutual TLS. I generated a private CA, signed a certificate for the nginx server with an IP Subject Alternative Name (SAN), and configured Caddy to verify it against my CA cert. This means even if the WireGuard tunnel were somehow compromised, the traffic inside it is still encrypted and authenticated.

nginx

nginx serves the static files. The configuration is deliberately minimal, TLS 1.3 only, GET and HEAD requests only, sensible timeouts, and X-Real-IP forwarding so access logs show real client IPs rather than the VPN gateway address. The VM itself lives in a DMZ with no access to my trusted or wireless subnets.

Wrapping Up

Is this overengineered for a personal blog? Absolutely. But every layer taught me something; pf rules, WireGuard configuration, certificate management, mutual TLS, nginx hardening. I mean, if you're going to run a homelab, you may as well make it production-grade.

The architecture diagram below summarises the full stack. If you have questions or spot something worth improving, feel free to reach out.

sandover.ca architecture diagram

Are three layers of encryption necessary for a personal blog? No. Do I regret it? Also no.