Skip to content
← all projects

shipped

A portfolio, technical blog, and live self-hosted LLM demo built as a monorepo with a Next.js front end, a zero-dependency Go backend, and layered abuse defenses, self-hosted on a single Hetzner box.

Next.jsGoMDXSelf-hostedSecurity

update log

  • Jun 30, 2026First writeups published; retired the placeholder content.
  • Jun 29, 2026Initial pass. Front end, Go API, and deploy stack stood up end to end.

Project Overview

This portfolio website is meant to mirror my enthusisam for technology, engineering, DIY builds, and data privacy / security. How tokens stream from a self-hosted model, how the backend stays dependency-free, and how abuse is kept out an interesting domain, hence my desire to build as I have.

The basic monolithic repo structure:

  • web/: Next.js 15 (App Router, TypeScript, Tailwind). MDX files are the CMS.
  • api/: a Go backend on the standard library only, zero third-party modules.
  • packages/contract/: the single source of truth for the browser↔backend wire shapes.
  • deploy/: docker-compose.yml + Caddyfile for the whole stack.

The browser only talks to one backend

The load-bearing rule ingress security rule. The browser never reaches a third party directly; not the model, not the email provider, and not the spam-check service. Every secret lives in the Go API's environment, never in a NEXT_PUBLIC_* variable that would be inlined into the client bundle. To swap the model you just change one URL on the server; the front end never knows the difference, our decoupling in action.

Content is git, not a database

There's no CMS and no database for content. A project or post is one MDX file with YAML frontmatter, validated at build time so a malformed file fails the build instead of shipping a broken page. For example, adding this was: drop in a file, commit, deploy.

// A missing or empty required field throws at build (loud, not silent).
function requireString(fm: any, key: string, slug: string): string {
  const v = fm[key];
  if (typeof v !== "string" || v.trim() === "") {
    throw new Error(`content/${slug}: frontmatter "${key}" must be a non-empty string`);
  }
  return v;
}

MDX is treated as trusted-author input only, it compiles to JSX and can run code at build time, which is fine because every file is authored by me and committed to git. User input (the contact form) is email-only and is never rendered back into a page.

Zero dependency backend

The Go API has no third-party modules by design. Rate limiting, the SSE proxy, config loading, the spam traps are all standard library. It keeps the supply-chain surface at effectively zero and forces me to actually understand the moving parts instead of importing them.

The KobeLLM service I wrote is an examples of this: a small proxy relays an upstream OpenAI-style streaming response to the browser as simplified Server-Sent Events, flushing tokens as they arrive.

SSE > Websockets for KobeLLM chat interface

For one-directional, server-to-client token streaming, SSE is the simpler, more robust choice:

  • It rides on plain HTTP, so it works through proxies (Caddy, Cloudflare) with no upgrade handshake to misconfigure.
  • Automatic reconnection is built into the browser's EventSource.
  • There's no bidirectional channel to secure, the client sends one request, the server streams the answer.

The one catch: EventSource only does GET. Because my requests carry a JSON body (the chat history), I stream from a fetch() response and parse the SSE frames myself.

The proxy in the middle

The browser never talks to the model directly; it talks to my Go backend, which proxies to the inference engine. That indirection is where the safety controls live:

// Disable proxy buffering so tokens flush to the client immediately.
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("X-Accel-Buffering", "no")

The proxy enforces per-IP and global rate limits, a hard token cap, and a kill-switch, none of which I'd want to trust a browser to honor.

Graceful failure

A /health check gates the UI, and any upstream error becomes a clean "KobeLLm demo offline" message instead of a spinner that never resolves.

Abuse defenses

A public demo that calls a model is an obvious target, so the defenses stack:

  • In-memory per-IP and global rate limiting on both the contact and demo endpoints.
  • The contact form pairs a honeypot field and a submit-time trap with Cloudflare Turnstile.
  • The demo has a kill-switch plus hard token and input-length caps.
  • Per-IP identity trusts CF-Connecting-IP only once ingress is locked to Cloudflare.

On the front end, middleware.ts sets a strict, per-request nonce-based Content Security Policy, the primary defense against injected scripts.

const csp = [
  `default-src 'self'`,
  `script-src 'self' 'nonce-${nonce}' 'strict-dynamic' ${turnstile}`,
  `connect-src 'self' ${apiUrl}`,
  `frame-ancestors 'none'`,
  // …
].join("; ");

Deployment

The whole stack runs on a single Hetzner box behind Caddy and Cloudflare, deliberately not a managed platform. Caddy terminates TLS and reverse-proxies; Cloudflare fronts it; the Go binary and the Next.js standalone server run as containers. It's a small machine doing an honest amount of work, and I own every layer of it.

What's next

  • A real evaluation harness for the demo model's responses.
  • Finishing the security notes into a public, readable writeup.
  • More project writeups as the work lands.
This Site (kobeyoung.net) — Kobe Young