Selected work · 01 of 04
The platform under it all
One rented box, every app I run, and nothing on it I can't explain.
Why run it myself
Every app in this portfolio lives on a single VPS that I set up and look after. The reason is not thrift. The free services my GitHub profile depended on died one after another: a stats card host paused its deployment, another started returning payment errors, a third stopped answering. Each time, my profile broke because someone else stopped paying.
So I moved the whole thing in-house. If something on my servers breaks, it is because of a decision I made, and I can find it.
The shape of it
Traffic enters through Cloudflare, passes a firewall that accepts only Cloudflare’s own address ranges, and reaches Caddy, which terminates TLS and routes by host name to containers on a shared network. Admin tools sit behind Authelia single sign-on. Prometheus, Loki and Grafana watch all of it, and Alertmanager tells me when to look.
Decisions that mattered
Docker walks straight past the firewall. Docker writes its own iptables rules ahead of ufw, so a ufw rule that denies port 443 does nothing for a published container. The lock lives in Docker’s own DOCKER-USER chain instead: ports 80 and 443 accept Cloudflare’s ranges and drop everything else. The script refuses to run if either range list comes back empty, because an empty allowlist is a self-inflicted outage.
Only trust the edge about who is visiting. Caddy reads the visitor’s real address from Cloudflare’s header, but only when the request actually came from a Cloudflare address. Anyone reaching the origin directly cannot claim to be someone else.
Secrets never touch the disk. Apps get their secrets from Infisical at start-up, injected into the process and never written to a file. When a secret changes, Infisical calls a small relay that checks an HMAC signature with a constant-time comparison, rejects anything older than five minutes, and asks GitHub to redeploy the app that owns the secret. Rotating a key is one edit; the rest happens on its own.
Every container has a ceiling. Each service runs with a hard memory limit. Infisical’s default Node heap let it climb to about 650 MB inside a 768 MB box, so its heap is capped at 512 MB. On a shared machine, one greedy process is everyone’s outage.
Nothing ships by hand. Deploys go through GitHub Actions or not at all. A manual git pull on a server is a deploy no one can reproduce.
What broke, and what it taught
- The firewall blocked my own builds. Dropping 80 and 443 also caught containers talking out, so image builds quietly failed to download packages. The fix was a narrow exception for traffic leaving the internal container range. Lesson: a firewall rule has two directions, even when you only meant one.
- A test lied to me. A batch of
curlchecks reported the origin as blocked when it wasn’t. I switched to raw TCP probes, which test exactly the thing in question and nothing else. - Certificates cover one level. Cloudflare’s free certificate covers
app.example.combut notstg.app.example.com. Two-level names that skipped the proxy were dropped by the firewall and timed out with no error. Staging hosts are now one level deep. - Moving DNS dropped email. Cloudflare’s record import silently left out the CNAME records that sign outgoing mail. Apps kept sending; inboxes stopped trusting them. Every DNS move now ends with a record-by-record comparison.
- GitHub gave up on my images. GitHub fetches README images through a proxy with a short timeout, and the stats card took about four seconds to render. Cards are now pre-rendered every four hours, and a failed render keeps the last good card instead of publishing a blank one.
- Monitoring cried wolf. Slow container-metric scrapes timed out and made healthy hosts look dead. Trimming the metrics to what I actually read stopped the flapping.
Where it stands
About twenty sites across four domains, each one a few lines of Caddy configuration, with logs kept for thirty days and metrics scraped every fifteen seconds. Adding an app means writing one site block and one deploy workflow. This site will be the next one.