Social MediaSoftware & Coding
background-shape

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.

case-details

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.