projects

Portfolio (this site)

status
live
domain
nathandebock.com
period
2026-05-26 → present
commits
95
source
private repo · this page describes the system it runs on
deploy
live SHA and timestamp rendered on /status

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

LayerChoiceWhy this one
FrameworkNext.js 16 + React 19 + TypeScript strictSame stack as three of the client projects — knowledge transfer
RenderingStatic generation, one force-dynamic route19 prerendered pages; only the streaming endpoint is server-rendered
StylingTailwind v4, CSS-first via @themeTokens in one place, no tailwind.config.js
ScrollLenis driven by the GSAP ticker, single RAF loopOne frame loop instead of two competing ones
ContentMarkdown via react-markdown + remark-gfmCase studies as flat .md, parsed at build by a hand-rolled front-matter reader
TypographyPP Neue Montreal + PP Neue Montreal Mono + Inter, self-hostedSame foundry for sans and mono, no external font requests
Runtimeoutput: 'standalone', Node 24 Alpine, non-rootTraced dependencies only; the image ships server.js, not the repo
HostingScaleway VPS + Docker + host nginx + Let's EncryptFixed cost, full control, identical to the client pattern
Testsnode:test via tsx, 118 assertions over the REPL and content layersNo 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:

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

MetricValue
Span2026-05-26 → present
Commits95 across 10 active days
Conventional breakdown40 feat · 17 fix · 16 docs · 16 chore · 4 refactor · 1 ci
TS / TSX / CSS source4,458 LOC across 110 tracked files
Routes17 prerendered · 1 dynamic
Client case studies4, totalling 5,824 words and 34 GitHub permalinks
Post-mortems2
Test assertions118, 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.