keateswebsite
michaelkeates.co.uk
Personal portfolio and blog, live at www.michaelkeates.co.uk โ a fully self-hosted, content-managed rebuild of my original Next.js site, keeping its glassmorphism look while making every part of it editable from a CMS.
Tech stack
| Layer | Choice |
|---|---|
| Framework | Next.js 16 (App Router, React Server Components) |
| CMS | Payload CMS 3 (Postgres adapter, blocks, globals, native dashboard widgets) |
| UI | Chakra UI v3 + Tailwind 4, framer-motion fade-ups, glassmorphism design system |
| 3D | three.js โ an interactive avatar model on the home page |
| Database | PostgreSQL 16 |
| Cache | Valkey 8 (open-source Redis fork) via a Next.js cache handler |
| Runtime | Docker Compose on Proxmox VMs โ separate dev and production environments |
| Git hosting | Self-hosted Gitea |
| Edge | Cloudflare Tunnel (no open inbound ports) |
Highlights
- Everything is content-managed. Pages are composed from reusable Payload blocks โ profile header, experience timeline (LinkedIn-style multi-role support), certifications, icon grids, social icon rows, layout grids, video/X embeds โ so new sections need no code. Site title, favicon, background colours, a tabbed settings panel, search and social buttons are all CMS settings too, and rich text supports per-selection fonts alongside the usual formatting.
- Configurable mega-menu: a CMS-built navigation dropdown with per-section column grids (1โ4 across), a Lucide icon on any link, and an auto-populating "latest posts" section โ no code to add or rearrange menu items.
- Real-time live preview: editing a post updates the preview as you type (Payload's
useLivePreview), and the admin light/dark toggle cross-fades smoothly. - Interactive 3D avatar with a seasonal wardrobe: the CMS schedules alternative models by date range (a Halloween ghost with a "BOO" animation, Santa, shirt and cap variants), all generated procedurally with three.js scripts in this repo.
- Theme sync: the site's light/dark toggle and the Payload admin share one cookie, so flipping either side updates both.
- Live ops dashboard inside the Payload admin: content stats, Valkey cache metrics with a live performance graph, and Cloudflare analytics โ 7-day totals, a 30-day traffic chart, a world bubble-map of requests by country, and a top-countries list, all fed by Cloudflare's GraphQL API. A team message board widget lets admins post feature requests and replies (with avatars and status) right on the dashboard.
- WordPress importer: upload a WXR export to pull categories, media, posts and pages straight into Payload, with an optional overwrite mode to re-sync updates from WordPress.
- Portfolio straight from git: the Portfolio page lists repositories live from my Gitea instance, and each project page renders the repo's README server-side โ this very document is rendered there.
- Multi-site from one codebase: the same repo powers independent deployments (my personal site plus client sites) โ each its own VM, database, domain and branding, driven entirely by env vars and CMS content with no code forks. A fresh install builds its whole schema automatically from Payload migrations on first boot.
- Production hardening: immutable Docker image builds, named volumes, nightly database + media backups with retention, and a Cloudflare Tunnel so the origin exposes nothing to the internet.
Architecture
Cloudflare (DNS + Tunnel)
โ
โผ
Production VM โโ docker compose: Next.js (standalone build) ยท Postgres ยท Valkey
โฒ โ
โ git pull main + image rebuild โ content authored here
โ โผ
Gitea (self-hosted) โโโ push dev / merge main โโ Dev VM (hot reload, schema push)
Code flows dev โ Gitea โ production; content flows the other way (production is the source of truth, dev refreshes from its backups). Dev uses Drizzle's schema push for fast iteration; each schema change is captured as a Payload migration that every production instance applies automatically on deploy โ so one main branch safely fans out to multiple sites.
Development
cp .env.example .env # set secrets
docker compose up -d # dev stack: next dev with hot reload
Production runs the same repo through docker-compose.prod.yml (multi-stage standalone build) and deploys with a single script: pull main, rebuild the image, restart the stack.
Full setup and operations documentation lives in docs/SETUP.md, and the process for standing up a new client site in docs/NEW-CLIENT.md.