Self-Hosted Stack & CD Pipeline
This site and the services around it — a static build shipped by CI on every push, a workflow-automation runner, and a small API service — on servers I administer myself.
Client
Personal
Service
Infrastructure & Automation
Date
August 1, 2024
Challenge
Personal infrastructure gets the least attention and the same threat model as anything else on the public internet. It has to deploy reliably, survive being ignored for months, and not become somebody else's compute.
Solution
A static site built and deployed by CI with a scoped key, one explicit virtual host per site plus a catch-all that closes unmatched connections, ingress restricted to the CDN's published address ranges so the edge cannot be bypassed, and automated certificate renewal.
What runs here
This site is a static build. A push to the default branch triggers a pipeline that builds it, verifies the output actually rendered before going further, and mirrors the result to the server over a scoped deploy key. The whole cycle finishes in seconds, and because the deploy is a mirror rather than a copy, the served directory always matches the build exactly — anything placed there by hand is gone on the next push.
Alongside it: a self-hosted workflow automation runner, and a small internal API service that is bound to the private network rather than exposed publicly.
Two things I got wrong first, and fixed
A web server will happily serve the wrong site. When a hostname does not match any
configured virtual host, nginx serves it from whichever block is first on that port. That
is not a hypothetical — pointing a new domain at a server that already hosted one site
produced a redirect to the wrong domain, and repeating the mistake on a different server
served an entirely unrelated application’s UI. Neither was a DNS or CDN problem, which is
exactly why both took a while to diagnose. The fix is boring and permanent: an explicit
catch-all virtual host holding default_server on both ports, returning a closed
connection, with a throwaway self-signed certificate that exists only because a TLS
listener requires one.
A CDN in front of a server is not a control until the server enforces it. An edge that provides WAF filtering and rate limiting only helps for traffic that goes through it. If the origin still answers requests addressed to it directly, every protection the edge offers is optional from an attacker’s point of view. So the firewall is the actual control: ports 80 and 443 accept connections only from the CDN’s published address ranges, and anything else is dropped rather than refused.
Worth knowing before doing this: add the allow rules before removing the blanket ones, or the site is unreachable in the gap. And the origin now looks dead to any direct request — a timeout there means the lock is working, not that the web server is down. Test through the domain.
A diagnostic habit came out of the virtual-host bug that is worth keeping: request a unique path through the edge, then grep the access logs on each candidate origin to see which one actually answered. That settles “who served this” in one step, where reasoning about configuration can go in circles for an hour.
Stack
Hugo, GitHub Actions, nginx, Let’s Encrypt, ufw, n8n, FastAPI, PostgreSQL, Ubuntu.