← All work

Self-hosted server infrastructure

The box you are reading this on. One small VPS serving five hostnames and a smart-home platform, with automatic HTTPS, a private VPN, an MQTT broker and push-to-deploy CI — and no vendor cloud anywhere in the path.

Live — you're on it 2026 Solo — provisioning, hardening, deployment

The problem

The same server had to do three unrelated jobs at once: keep serving static sites that already owned ports 80 and 443, add a smart-home application with its own web server, and expose an MQTT broker that a Raspberry Pi hub could reach over the internet — all without port collisions, without hand-managing TLS certificates, and without installing a frontend build toolchain on the server itself.

By the numbers

5
hostnames served
5
containers
2
vCPUs
~220
MiB total RAM used

What I built

Two decisions worth explaining

Why the proxy is two tiers, not one

Merging everything into one Caddy config would have worked — and would have welded the application to this particular server. Instead the app stack carries its own web server that binds to loopback, so it can drop onto a box that already runs a web server (this one), or onto a bare box where an optional profile lets it terminate TLS itself. The application is portable; the edge proxy is just plumbing.

Routing before the fallback

A single-page app's catch-all fallback is a trap for machine clients. If /sync/*, /ws and /healthz fall through to it, they get index.html with a 200 OK — so the Raspberry Pi's "did that request succeed?" check passes and rule syncing fails silently later when it tries to parse HTML as JSON. Worse, an uptime monitor watching /healthz would report everything green while the backend was down.

Those three routes are declared explicitly ahead of the SPA fallback, with the reasoning written next to them in the config so the next person doesn't "simplify" it back into a bug.

Stack

Ubuntu 24.04 LTS Docker & Compose Caddy 2 Let's Encrypt / ACME Eclipse Mosquitto (TLS) PostgreSQL + TimescaleDB WireGuard ufw GitHub Actions HTTP/3

Current state

Five containers, all running, using about 220 MiB of RAM between them (measured August 2026) on a 2-vCPU box that idles near zero load. The deployed revision on the server matches the repository's HEAD, and the pipeline has run 30 times with 25 green deploys (last verified green: 12 Aug 2026).