A statically generated Next.js 16 app in a standalone Docker image, on a self-hosted Scaleway VPS behind host nginx, with exactly one dynamic route. No vendor platform, no serverless functions, no managed database — the same deployment pattern as the three client projects next to it, which is the whole reason it is worth documenting.
Context
Constraints set before the first commit:
- Solo, self-hosted. No Vercel. The site had to deploy the same way the client work deploys, because that is the thing being demonstrated.
- Every metric real. Anything on screen is measurable or renders an honest
—until its source is wired. Two values are still—today, and they say so. - Design contract locked first.
CLAUDE.md— ten hard rules, ten explicit anti-patterns, the voice, the layout principles, and the dimensions deliberately not optimized for. Written before any code and read at the start of every session.
Stack
| Layer | Choice | Why this one |
|---|---|---|
| Framework | Next.js 16 + React 19 + TypeScript strict | Same stack as three of the client projects — knowledge transfer |
| Rendering | Static generation, one force-dynamic route | 19 prerendered pages; only the streaming endpoint is server-rendered |
| Styling | Tailwind v4, CSS-first via @theme | Tokens in one place, no tailwind.config.js |
| Scroll | Lenis driven by the GSAP ticker, single RAF loop | One frame loop instead of two competing ones |
| Content | Markdown via react-markdown + remark-gfm | Case studies as flat .md, parsed at build by a hand-rolled front-matter reader |
| Typography | PP Neue Montreal + PP Neue Montreal Mono + Inter, self-hosted | Same foundry for sans and mono, no external font requests |
| Runtime | output: 'standalone', Node 24 Alpine, non-root | Traced dependencies only; the image ships server.js, not the repo |
| Hosting | Scaleway VPS + Docker + host nginx + Let's Encrypt | Fixed cost, full control, identical to the client pattern |
| Tests | node:test via tsx, 118 assertions over the REPL and content layers | No framework; the runtime already has a runner |
Shipping it
Two-stage Dockerfile. The builder installs from a frozen lockfile, then output: 'standalone' traces the module graph so the runner stage copies server.js, the traced node_modules, .next/static and public — and nothing else. It runs as the node user with a wget healthcheck against /.
Node is pinned to 24 on both stages for a specific reason: jsdom reaches html-encoding-sniffer, which is ESM-only and needs Node 22+'s require(ESM). Node 20 fails at build during page-data collection and at runtime the first time the REPL scrapes a URL. The pin is a comment in the Dockerfile, not folklore.
.git is excluded from the build context, which means the image cannot read its own commit. The deploy workflow therefore reads the SHA and the commit date on the VPS host, where the checkout lives, and passes them in as build args to be inlined as NEXT_PUBLIC_ values. That is why /status can show a real last-deploy timestamp without calling the GitHub API — and why an unset build arg makes it render — instead of a plausible-looking lie.
Push to main triggers a GitHub Action: lint, typecheck, tests and a full build run on a runner first, and only then does the deploy job SSH in, pull, docker compose build && up -d on a fixed loopback port, and prune. Host nginx already proxies to that port, so there is no reload step. A health check polls the public URL twelve times before the workflow is allowed to pass.
The streaming service
/api/repl/match is the only dynamic route on the site. It takes a job posting — a URL or pasted text — and streams back a structured fit report.
The interesting parts are the transport, not the model:
- NDJSON over a
ReadableStream. One JSON object per line, so the client renders tokens as they arrive instead of waiting for a complete response. - The first event is peeked before the response opens. Without that, a rate-limit rejection would arrive as HTTP 200 containing an error object, because the status is already committed once streaming starts. Peeking lets a leading error become a real 400 or 429.
X-Accel-Buffering: no. The reverse proxy buffered the entire stream and delivered it in one chunk, which made the streaming invisible. One header, found by watching the response in production rather than locally.- A fixed-window per-IP limiter in memory, resetting on container restart. Deliberately not Redis: one container, one process, and the failure mode of losing the counters on restart is acceptable for this.
- Prompt caching. The profile block is static across every request and marked
cache_control: ephemeral, so repeat calls do not re-pay for it.
The whole thing is a dependency-injected async generator — fetch, the stream factory, the clock and the rate map are all passed in — which is why it has 10 unit tests covering rate limiting, URL detection, scrape failure and the error-to-status mapping, with no network access.
Security posture
Full CSP with a documented exception. script-src and style-src keep 'unsafe-inline'; everything else — frames, objects, base URI, form actions, cross-origin fetches, images and fonts — is locked to 'self', with object-src 'none' and frame-ancestors 'none'.
The exception is a measured trade, not an oversight. A nonce-based policy must issue a fresh nonce per request, which forces every page off static generation. Testing a nonce-free 'self'-only script-src broke hydration outright: 19 blocked inline scripts, because Next's own flight-data and hydration scripts are inline. style-src needs it separately for the inline style attributes on the spotlight cursor and the status dots. The reasoning is a comment above the policy so the next person to touch it inherits the measurement rather than the conclusion.
Alongside it: HSTS with preload and a two-year max-age, X-Frame-Options: DENY, nosniff, a strict referrer policy, and camera/microphone/geolocation denied outright.
Incidents
Two post-mortems, both written after the fact and both about detection rather than about the bug:
- Hydration mismatch on the home page — React #418 in every visitor's console for 58 days, caused by a client component computing a relative date that the server had frozen at build time.
- Scroll dies after the first client-side navigation — 74 days of dead wheel input after any in-app link click, because a
height: 100%on<html>froze the smooth-scroll library's cached page height.
How it was built
Most of the first-draft code in this repository was written by a coding agent working against CLAUDE.md, the design contract described above. Architecture, the design direction, and the gray-area calls are mine, and every diff was read before it merged. The reading is the part worth being specific about, because it is the part that varies.
It caught 2d1653f: a react-markdown link renderer spreading node and props after rel="noopener noreferrer", so any external link in a case study could silently lose it. Code that compiled, rendered correctly, and was wrong.
It did not catch the scroll regression. That one shipped and survived review for 74 days, because every automated check in the repo loaded a route directly and none of them ever navigated from one page to another — the review was reading diffs, and the defect only existed in an interaction no diff describes.
Reviewing a diff tells you whether the code is defensible. It does not tell you whether the system works.
Outcomes
| Metric | Value |
|---|---|
| Span | 2026-05-26 → present |
| Commits | 95 across 10 active days |
| Conventional breakdown | 40 feat · 17 fix · 16 docs · 16 chore · 4 refactor · 1 ci |
| TS / TSX / CSS source | 4,458 LOC across 110 tracked files |
| Routes | 17 prerendered · 1 dynamic |
| Client case studies | 4, totalling 5,824 words and 34 GitHub permalinks |
| Post-mortems | 2 |
| Test assertions | 118, gating every deploy |
| Public traffic | — until Plausible is wired |
Retrospective
The design contract earned its keep. Locking ten hard rules and ten anti-patterns before any code meant later sessions refused whole categories of generic output — gradients, skill bars, "currently learning" sections, emoji status indicators — without negotiation. The two times the rules tightened mid-project, they tightened by adding rigor.
No test runner existed for two months. 95 assertions sat in the repo, fully written, with no test script in package.json and no CI step to execute them. They were not failing; they were not running — which is worse, because the repo looked tested. Wiring a runner and a CI gate took one commit, and the only reason it took two months is that nothing ever asked.
Automated checks tested pages, never transitions. This is the single most expensive lesson here. Playwright captures, Lighthouse runs and manual passes all loaded routes directly, and the site's near-total lack of internal links meant even manual navigation happened by typing URLs. A whole class of defect was structurally invisible.
The agent role files were stubs for two months. The four .claude/agents/*.md files were created empty and only populated later. Behaviour was correct anyway because the contract carries the expectations in prose, but a file that claims to define a role and does not is a small lie in the repo.
Two metrics are still —. Visitor counts need Plausible wired; project health needs a real ping rather than a hand-typed lastCommitDate. Both render honestly today, and both are the next things to build.