Skip to content
Owden Magnusen, home
Writing

How this site is built and deployed

Typed content that fails the build when it is wrong, mostly static pages on Vercel, a portable Docker image, and CI that checks accessibility and performance.

  • Next.js
  • Vercel
  • Docker
  • CI/CD

This site is small, but it is built the way I build production systems: typed contracts, a reproducible build, a health endpoint, and a pipeline that refuses to ship regressions. This note walks through each layer.

Content as typed data

Every fact on the site lives in typed files under src/content/. Each file passes its data through a Zod schema when it is imported, and the root layout imports all of them. A malformed date, a duplicate project slug, an http:// link, or an em dash in the copy fails next build with the file and field named:

export const skillGroups = defineContent("skills.ts", skillGroupsSchema, [
  { id: "databases", label: "Databases", skills: ["PostgreSQL", "Redis", "Supabase"] },
]);

Case studies and posts are MDX. Each file exports a metadata object, and a small registry validates it the same way. Unknown facts are explicit TODO markers rather than guesses, and a test collects every one of them.

Almost everything is static

Pages are React Server Components rendered at build time. The only client JavaScript is the terminal in the hero, the animated header wordmark, the mobile menu, the scroll fade and card glow, the contact form, and two copy buttons. Analytics (Google tag and PostHog) are off unless configured, and even then nothing loads until the visitor accepts the cookie banner. The two API routes, /api/contact and /api/health, are the only things rendered on request.

Hosting on Vercel

The site deploys to Vercel from GitHub: every push to the production branch builds and ships, and every pull request gets its own preview URL. Rolling back is pointing production at an earlier deployment, with no rebuild.

Values that end up inside prerendered pages or headers, such as the analytics ids and the CSP mode, are build-time variables. Secrets for the contact form are runtime variables and never reach the browser. GET /api/health returns the commit the deployment was built from, which makes it easy to confirm what is live.

Hosting on serverless functions changed one design choice. The contact form's rate limiter (5 messages per 10 minutes per IP) lives in memory, and requests can land on different function instances, so on its own it is only a best-effort limit. A honeypot field catches simple bots, and Cloudflare Turnstile handles the rest. The limiter reads the client IP from X-Forwarded-For, which Vercel overwrites with the real address, so a client cannot spoof it.

A portable image as a fallback

output: "standalone" makes next build trace exactly the files the server needs into .next/standalone: a server.js and a minimal node_modules. Vercel ignores it, but it means the same app also runs anywhere Node does.

The Dockerfile packages that output in three stages:

  1. deps installs from the lockfile with a cached pnpm store.
  2. build runs next build, with the same build-time values as build arguments.
  3. runner starts from node:24-alpine and copies only the standalone output. It runs as an unprivileged nextjs user.

The health check uses Node's built-in fetch, so the image needs no curl:

HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \
  CMD ["node", "-e", "fetch('http://127.0.0.1:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]

I use it to check a production build locally with docker compose up --build, and it keeps the site one command away from any other host.

Security headers, measured

The Content Security Policy started strict: scripts from 'self' plus a hash for one inline script. Run in report-only mode, a browser recorded three inline script violations on every page. Next.js streams page data through inline scripts that differ per page and per build, so no hash can cover them, and nonces would make every page dynamic. The policy now allows inline scripts, keeps everything else locked down (object-src 'none', frame-ancestors 'none', form-action 'self'), and stays report-only until production reports come back clean.

CI

Every push runs lint, formatting, type checking, unit tests, and the production build, then Playwright against that build. The browser tests cover more than smoke checks:

  • a crawler that follows every internal link and fails on any dead one
  • axe accessibility checks on every core page
  • real Largest Contentful Paint and layout shift measurements
  • the CSP violation check above
  • the contact form, with its API mocked

A Lighthouse step then audits six pages in mobile mode and fails below 95 for performance or below 100 for accessibility, best practices, or SEO. Simulated throttling varies between runs, so each page is audited three times and the median counts.

Vercel builds and deploys each push. CI runs alongside it, and the production branch only accepts changes that pass CI.