Personal site for Redick "Red" Butay — a Next.js 15 port of the approved
single-file design prototype (Portfolio.html), set up as a static export
so it can live on any static host or behind the homelab's Caddy.
- Next.js 15 (App Router) + TypeScript
- Tailwind CSS v4 via
@tailwindcss/postcss, with@themetokens that mirror the CSS custom properties from the prototype (--bg,--fg,--accent, …) - next/font for JetBrains Mono (300–700) and Inter (400–600). No CDN.
- Zero additional runtime dependencies beyond React / Next.
- Static export target —
next buildproduces a fully staticout/.
- Node
>= 20(.nvmrcpins the major) - pnpm
>= 10
pnpm install
pnpm devLocal URL: http://localhost:3000
While developing:
pnpm lint— ESLint (Next core-web-vitals + TypeScript rules)pnpm typecheck— stricttsc --noEmitpnpm build— production static export toout/
The repo is configured with output: "export", so every build writes a
complete static site to out/. Point your web server / CDN at that
directory.
Set one variable before pnpm build so sitemap / OG URLs resolve to the
real origin:
| Variable | Purpose | Example |
|---|---|---|
NEXT_PUBLIC_SITE_URL |
Canonical site origin (no trailing slash). | https://portfolio.redick.dev |
If unset, the build falls back to https://redick.butay.example so the
build still succeeds but search engines and OG scrapers will see a stub
URL. Override it before shipping.
Drop out/ on the server and point a Caddy site at it:
portfolio.your-domain {
root * /srv/portfolio/out
encode zstd gzip
file_server
try_files {path} {path}.html {path}/index.html
}The opengraph-image metadata route is exported as an extensionless file
(out/opengraph-image). It is served as image/png because the <meta property="og:image:type"> declares it; most scrapers respect that. If
you host behind a server that is aggressive about mime sniffing, you can
add a content-type hint:
@og path /opengraph-image
header @og Content-Type image/pngout/ works as-is on:
- Vercel / Netlify / Cloudflare Pages (point at the repo; set build
command to
pnpm build, output dir toout) - GitHub Pages (publish
out/from a workflow) - Plain
nginx(root /path/to/out; try_files $uri $uri.html $uri/ =404;)
The repo ships a Dockerfile, Caddyfile, and docker-compose.yml
for running the site as a container. The image is a two-stage build:
stage one runs pnpm build with Node + pnpm, stage two is Caddy
serving out/ on HTTP :80 with gzip/zstd and tuned cache
headers. The compose file contains only the web service — it's
deliberately minimal so it slots under whatever reverse proxy or
tunnel you already run (Cloudflare Tunnel, an existing Caddy,
Traefik, nginx, …).
On the target server:
git clone <this repo> portfolio
cd portfolio
# Public origin — baked into <meta>, sitemap.xml, OG image, JSON-LD.
cat > .env <<EOF
NEXT_PUBLIC_SITE_URL=https://portfolio.your-domain.com
EOF
docker compose up -d --buildThe container now listens on HTTP :80 inside the portfolio Docker
network. No host ports are published.
Rebuild after content edits:
docker compose up -d --buildLogs / shell:
docker compose logs -f web
docker compose exec web shIf your cloudflared runs in its own compose stack (recommended —
keeps the tunnel reusable across projects), attach it to the
portfolio network so it can resolve the container:
docker network connect portfolio <your-cloudflared-container>Then in the Cloudflare Zero Trust dashboard, point the public-hostname
route for this site at http://web:80 (or http://portfolio:80
— both resolve inside the network). Cloudflare terminates TLS at
the edge; the portfolio container stays HTTP-only internally.
The same pattern works for any host-side reverse proxy: connect it to
the portfolio network, target http://web:80.
If you'd rather have Caddy terminate TLS itself:
- In
Caddyfile, replace the:80 {site block withportfolio.your-domain.com {(your real hostname). Caddy will auto-issue a Let's Encrypt cert. - In
docker-compose.yml, addports:for 80, 443, and443/udp, plus acaddy-datanamed volume mounted at/dataso cert state survives restarts. - Make sure DNS points at the server and ports 80/443 are open to the public internet (HTTP-01 challenge needs port 80).
docker compose up -d --build
docker run --rm -p 8080:80 portfolio:latest # temporarily publish
# browse http://localhost:8080Almost every piece of text, every card, every list on the page is driven
by a single file: src/lib/content.ts. Open it and you'll see one
named export per content block. Change the data, save, and pnpm dev
hot-reloads the section.
You do not need to touch JSX for any of the edits below.
| What you want to change | Edit this |
|---|---|
Hero copy (Software developer., bio paragraph) |
src/components/Hero.tsx |
The key-value list in the hero (name, role, …) |
heroFacts in src/lib/content.ts |
| The tag chips under the hero | heroChips in src/lib/content.ts |
The Hi, I'm Red. paragraph + the 3 "fact" tiles |
facts array + JSX in src/components/About.tsx |
| Timeline entries | timeline in src/lib/content.ts |
| Stack columns (Core / Frontend / Infra / Also) | stack in src/lib/content.ts |
| Project cards | projects in src/lib/content.ts |
| Open-source cards | ossRepos in src/lib/content.ts |
| Homelab host info + gauges | hostInfo in src/lib/content.ts |
| Homelab services grid | services in src/lib/content.ts |
now bullet list + side stats |
nowItems and nowSide in src/lib/content.ts |
now "last updated" label |
nowUpdated in src/lib/content.ts |
| Blog post index | posts in src/lib/content.ts |
| Contact links | contactLinks in src/lib/content.ts |
| Top-bar nav links | navLinks in src/lib/content.ts |
| Terminal command output (help, about, projects, …) | src/lib/commands.ts |
Page <title> / description / OG copy |
metadata in src/app/layout.tsx |
| JSON-LD Person schema (jobTitle, sameAs, …) | personSchema in src/app/layout.tsx |
| OG image art | src/app/opengraph-image.tsx |
| Colours / radii / spacing tokens | :root in src/app/globals.css |
| Light-mode token overrides | [data-theme="light"] in src/app/globals.css |
Add a new project. Open src/lib/content.ts, find projects, and
append one more object:
{
index: "[05]",
slug: "my-new-thing",
title: "My New Thing",
description: "One-sentence pitch.",
tags: ["next.js", "tailwind"],
status: "live", // or "building"
glyph: "⚡", // appears in the placeholder thumb
thumbLabel: "my-new-thing.png · 16:9 placeholder",
},TypeScript will flag any missing fields. Types live in
src/types/content.ts if you need to check the exact shape.
Add a timeline entry. Same file, timeline array. Mark active: true
on exactly the current entry (it controls the filled-circle marker);
remove it from the previous one.
Add a stack item. Pick a column in stack and push into its
items: { name: "Rust", years: "1y", level: 40 }. level is 0-100
and drives the progress bar.
Add a homelab service. Push into services:
{ name: "Grafana", desc: "dashboards", uptime: "up 3d", port: ":3100", state: "up" }state is "up" | "warn" | "off" and colours the dot.
Add a blog post. Push into posts:
{ date: "2026 · 05 · 01", tag: "[odoo]", title: "…", readTime: "5 min", href: "#" }Keep href: "#" until you actually have somewhere to link to.
Update the now list. Change nowItems, and bump nowUpdated to
the current month (e.g. "May 2026"). The 4 side stats (focus, listening,
last commit, coffee) are in nowSide.
Change the accent colour. In src/app/globals.css, the --accent
variable lives in both :root (dark) and [data-theme="light"]. Pick
new oklch() values for each. If you want to change the whole palette,
see the comment blocks — every token has dark + light pairs right next
to each other.
Change / add a terminal command. src/lib/commands.ts holds a
COMMANDS record where each key is the command name and the value is
a function returning { lines, effect? }. lines is an array of HTML
strings; effect can be { kind: "clear" }, { kind: "scroll", selector }, or { kind: "toggleTheme" }. Add your new key and it
shows up in Tab completion automatically. Remember to add it to the
help command's output too.
Edit a hint button below the terminal. Edit the HINT_COMMANDS
tuple at the top of src/components/Terminal.tsx.
The prototype ships with placeholder visuals. When you have real files,
drop them under public/ and wire them in:
- Avatar — put a square photo at
public/avatar.jpg(or similar) and replace the striped placeholder insrc/components/About.tsxwith anext/image<Image>(rememberimages.unoptimizedis on, so you can point it at any path). - Project thumbnails — drop 16:9 PNGs at
public/projects/<slug>.pngand replace the diagonal-stripe placeholder insrc/components/Projects.tsxthe same way. - OG image — the build generates one at
/opengraph-imagefromsrc/app/opengraph-image.tsx. Edit that file to change the art. - Favicon — the build generates a 32×32 PNG at
/iconfromsrc/app/icon.tsx(accent-red "R" on--bg). Edit that file, or drop afavicon.ico/icon.pnginsrc/app/to override it.
src/
app/
layout.tsx # fonts, <html data-theme>, metadata, pre-paint script, JSON-LD
page.tsx # section composition
globals.css # @theme tokens + ported prototype CSS
opengraph-image.tsx # 1200×630 OG image (ImageResponse)
icon.tsx # 32×32 favicon (ImageResponse)
robots.ts # /robots.txt
sitemap.ts # /sitemap.xml
components/ # TopBar, Hero, Terminal, About, Timeline, …
lib/
commands.ts # terminal command map (pure fns)
content.ts # typed content arrays (projects, stack, services, …)
site.ts # SITE_URL / SITE_NAME
theme.ts # applyTheme / readTheme + pre-paint script
types/
content.ts # shared content types
- Theme is persisted at
localStorage["rb.theme"]and applied before first paint by an inline script in<head>, so there is no flash-of-wrong-theme. It can be toggled by the top-bar button, theTkey, the terminal'sthemecommand, or the Tweaks panel. - Terminal supports
help,about,projects,oss,stack,homelab,now,blog,contact,theme,clear,ls,whoami,pwd,date,sudo,exit— plus ↑/↓ history and Tab completion. The global/shortcut focuses the input. - Tweaks panel listens on
window.messagefor__activate_edit_mode/__deactivate_edit_modeand posts__edit_mode_availableon mount and__edit_mode_set_keyson change — unchanged from the prototype so any host iframe wiring keeps working. - Reduced motion disables the hero cursor blink and the project "building" status pulse.
Portfolio.html— the approved design prototype. Kept in-repo as the source of truth for any visual question.CLAUDE_CODE_HANDOFF.md— the porting brief that produced this build.