PROJECT / SELF-HOSTED PLATFORMProduction habits,
one VPS.
Everything I publish runs on a single hardened virtual server I operate the way I ran datacenters: one public entry point, secrets delivered at boot rather than stored, containers with the least privilege they can work with, and every change reviewed and tested before it ships. This page is the live infrastructure behind the site you are reading.
Rocky Linux 10Docker Composenginx edge proxyGitLab CE + runnerOpenBaoTailscale
AI contribution: AI-assisted runbooks and compose hardening; architecture and access decisions are mine. How I work with AI
01 / WHAT RUNS HEREThe same questions an assessor asks of a datacenter apply to one box: who can reach it, where the secrets are, what a compromised container can do, and how a bad change gets undone.
EDGE PROXYOne public door
An unprivileged nginx container owns ports 80 and 443. It passes GitLab's TLS through by SNI so GitLab keeps its own certificate, terminates TLS for this site, and redirects plain HTTP. Let's Encrypt renewal runs from a systemd timer through the same proxy's webroot.
SOURCE CONTROLGitLab CE and a runner
Every repository lives on a private GitLab on the VPS: projects private by default, push-to-create off, one write-enabled deploy key per project per agent so an AI coding agent can only touch what it was granted.
SECRETSOpenBao with AppRole
The site's chat API key never lives in git or in an image. A systemd unit authenticates with an AppRole at boot, renders the key into a root-owned tmpfs file, and the container reads it as an env layer. If the vault is down the site still serves; only the chat returns a friendly 503.
THIS WEBSITEHardened by default
The Next.js standalone image runs as a non-root user with a read-only root filesystem, every Linux capability dropped, no-new-privileges, tmpfs for caches, a health check, and bounded JSON logs. The same compose house style applies to the other services.
ADMIN ACCESSTailscale only
SSH and Git over SSH are reachable only on the tailnet; the public hostname refuses the Git port. Internal services bind to loopback or the tailnet address, never 0.0.0.0.
AUTOMATIONScheduled and reviewed
n8n handles scheduled jobs, an hourly sync reconciles the production checkout with GitLab, and every change to the site goes through a merge request and a test gate before it is built.
02 / HOW A CHANGE SHIPSThe deploy is deliberately manual and deliberately boring. The test gate on the host is the same one CI runs, so a merge that passed review cannot reach production without passing again on the machine that serves it.
- STEP 01
Open a merge request
Changes land on main only through a reviewed merge request on the private GitLab, including the ones AI agents author.
git push origin claude/site-improvements
- STEP 02
Run the gate on the host
The production checkout pulls main and runs the full suite: a build, rendered-HTML assertions for every route, a private-data exclusion check, and a 105 KB gzip budget for critical assets.
npm ci && npm test
- STEP 03
Build and replace the container
Compose builds the image from the repo Dockerfile and swaps the container; the health check gates traffic.
docker compose -f deploy/compose.yml up -d --build
- STEP 04
Verify, or roll back
A deep link and the chat are checked by hand. Rollback is a redeploy of the previous commit; there is no server state to migrate.
git checkout <previous-sha> && docker compose -f deploy/compose.yml up -d --build
03 / SAFEGUARDSEach guard was added after thinking about how the thing it protects would actually be abused or forgotten.
CHAT SPENDINGA paid endpoint with a hard ceiling
The public chat reserves its cost in SQLite before every upstream call: a monthly and daily allowance, six admitted requests per minute, two concurrent calls, reservations priced at twice the published rate and never refunded. Exhaustion returns a 503 with the reset time instead of a bill.
NO TRACKINGNothing to leak
No analytics, no cookies, no account system, no third-party scripts. The browser gets a Content-Security-Policy that allows only this origin.
CERTIFICATESRenewed without hands
A systemd timer renews the Let's Encrypt certificate through the proxy's ACME webroot and reloads nginx; the GitLab certificate has its own path that the proxy never touches.
BLAST RADIUSKeys scoped to one job
Each coding agent has its own deploy key enabled per project, never a personal access token. Any token that appears in a transcript is rotated.
04 / GET IN TOUCHThe compose files, proxy configuration, secret-delivery unit and deployment guide live in the site's private repository. Happy to walk through any of it, or to talk about how these habits scale to a team.
Email me about the platform →