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.
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
What I built
- Two-tier reverse proxy. A shared edge Caddy owns 80/443 (TCP and UDP, so HTTP/3 works) for every domain. The smart-home stack ships its own internal Caddy bound to loopback only, and the edge container joins the app's Docker network to reach it.
- Automatic HTTPS. Five Let's Encrypt certificates issued and renewed by Caddy's built-in ACME client. Plain HTTP answers with a permanent redirect. There is no certificate cron job to forget about.
- A split MQTT broker. The plain listener is deliberately not published — it stays on the Docker network for the backend only. The TLS listener is exposed publicly for the Pi bridge, with a private certificate authority, anonymous access disabled, a password file and per-device topic ACLs.
- Least-exposure networking. Publicly bound: SSH, 80, 443 and the TLS MQTT port. Loopback only: the API and the internal web server. PostgreSQL isn't published at all. A firewall and a WireGuard tunnel are both active.
- Deploy on push. GitHub Actions typechecks and builds the TypeScript workspaces, compiles the React bundle on the runner, ships the tree and the compiled assets over SSH, runs an idempotent deploy script with database migrations, and fails the run if the app doesn't answer afterwards.
- Unattended security updates on a schedule, and containers set to restart unless explicitly stopped — verified in practice when the host rebooted for a kernel update and everything came back on its own.
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
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).