Personal portfolio and writing site - Next.js, Tailwind, Three.js
  • TypeScript 87.4%
  • JavaScript 4.5%
  • CSS 4.3%
  • Shell 2.4%
  • Dockerfile 1.4%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
bots e25083e7e4
All checks were successful
deploy / vercel (push) Successful in 3m22s
copy: engineer first, security second
- hero: 'I built software for years. Now I take it apart.' replaces the break-things line
- role is Application Security Engineer, AppSec Engineer in the header
- about opens on the full-stack background instead of the 'I break things' framing
- intro, about strip, keywords and CTA copy follow the same voice
2026-11-03 19:56:15 -07:00
.forgejo/workflows ci: deploy to Vercel from Forgejo Actions 2026-11-03 17:38:41 -07:00
app copy: engineer first, security second 2026-11-03 19:56:15 -07:00
components copy: engineer first, security second 2026-11-03 19:56:15 -07:00
content/cache copy: engineer first, security second 2026-11-03 19:56:15 -07:00
deploy docs(deploy): how to get Forgejo code onto Vercel 2026-11-03 17:30:48 -07:00
lib copy: engineer first, security second 2026-11-03 19:56:15 -07:00
public feat: new portfolio site - Next.js, Tailwind, shadcn, three.js 2026-11-03 17:02:58 -07:00
scripts feat: new portfolio site - Next.js, Tailwind, shadcn, three.js 2026-11-03 17:02:58 -07:00
.dockerignore feat(deploy): self-host on a Raspberry Pi 2026-11-03 17:27:25 -07:00
.gitignore feat(deploy): self-host on a Raspberry Pi 2026-11-03 17:27:25 -07:00
components.json feat: new portfolio site - Next.js, Tailwind, shadcn, three.js 2026-11-03 17:02:58 -07:00
docker-compose.pi.yml feat(deploy): pi override for a box that already terminates TLS 2026-11-03 17:33:17 -07:00
docker-compose.token.yml feat(deploy): self-host on a Raspberry Pi 2026-11-03 17:27:25 -07:00
docker-compose.yml feat(deploy): self-host on a Raspberry Pi 2026-11-03 17:27:25 -07:00
Dockerfile feat(deploy): self-host on a Raspberry Pi 2026-11-03 17:27:25 -07:00
eslint.config.mjs Initial commit from Create Next App 2026-11-03 16:41:09 -07:00
next.config.ts docs(deploy): how to get Forgejo code onto Vercel 2026-11-03 17:30:48 -07:00
package-lock.json fix(deps): next 15.5.29 - Vercel refuses to deploy 15.5.4 as vulnerable 2026-11-03 19:18:53 -07:00
package.json fix(deps): next 15.5.29 - Vercel refuses to deploy 15.5.4 as vulnerable 2026-11-03 19:18:53 -07:00
postcss.config.mjs Initial commit from Create Next App 2026-11-03 16:41:09 -07:00
README.md docs: note the runner's host-gateway requirement 2026-11-03 18:55:23 -07:00
tsconfig.json Initial commit from Create Next App 2026-11-03 16:41:09 -07:00
vercel.json docs(deploy): how to get Forgejo code onto Vercel 2026-11-03 17:30:48 -07:00

portfolio

Personal site for John Paul Wile: security engineering, writing, and repositories. Next.js 15 (App Router) + Tailwind v4 + shadcn/ui + a hand-written WebGL grid. Static-first, no database, no auth.

What is on it

Route Content
/ Hero with the interactive 3D silicon stack, selected repos, recent writing, about strip
/work Every repository from the Forgejo API, with language + security filters
/writing All Substack posts, grouped by year, mirrored from the RSS feed
/about Bio, stack, experience, working principles
/contact Working form (Resend) plus direct channels and a disclosure note
/rss.xml, /sitemap.xml, /robots.txt Machine-readable surface

Old URLs redirect: /newsletter -> /writing, /projects -> /work, /tutorial -> /writing.

Data

scripts/sync-external.mjs runs on prebuild and pulls:

  • repositories from the Forgejo API (FORGEJO_URL, optional FORGEJO_TOKEN)
  • posts from the Substack RSS feed

Both are written to content/cache/*.json, which is committed. Pages read only the cache, so a dead API or a suspended account can never leave a page spinning or fail a deploy - the last good payload ships. If a fetch fails and no cache exists, the sync exits non-zero so the failure is loud once.

Refresh manually:

npm run sync

Environment

Copy .env.example to .env.local. Nothing here is client-side.

  • FORGEJO_URL - defaults to https://git.the424.com until git.botsone.dev exists
  • FORGEJO_TOKEN - optional; includes private repos, name-only, never linked
  • RESEND_API_KEY, CONTACT_FROM, CONTACT_TO - contact form delivery

Without RESEND_API_KEY the form returns 503 with a message pointing at the direct email address instead of pretending to send.

Development

npm install
npm run dev     # http://localhost:3000
npm run build   # runs sync, then a static-first build
npm run lint

Hero background

components/grid-background.tsx is a hand-written WebGL fragment shader: an infinite perspective floor grid that scrolls toward the viewer, with a light that follows the pointer and tints nearby lines orange. No three.js, no geometry

  • one fullscreen triangle.

It uses analytic pixel-footprint anti-aliasing so lines stay crisp at any depth, picks its line colour from the --foreground token (so it works in both themes), pauses when scrolled out of view or the tab is hidden, and draws a single static frame under prefers-reduced-motion. Without WebGL it degrades to a CSS gradient.

Deploy

Two targets, one build.

Vercel. Vercel only connects the Git providers it supports - GitHub, GitLab, Bitbucket. It cannot pull from Forgejo, so pick one of three routes:

  1. CLI deploy, no Git link. Works today:

    npx vercel login           # once
    npx vercel link            # once, writes .vercel/project.json
    npx vercel deploy --prod   # uploads the source, Vercel builds it
    

    Simple, but no per-branch previews, and nothing deploys until you run it.

  2. Forgejo Actions + CLI. Copy deploy/vercel-deploy.yml to .forgejo/workflows/deploy.yml once an Actions runner is registered, add VERCEL_TOKEN, VERCEL_ORG_ID and VERCEL_PROJECT_ID as repo secrets, and every push to main deploys. Forgejo stays the only source of truth.

  3. GitLab mirror for native Git integration (preview deploys, dashboard rollbacks). Create a push mirror in the Forgejo repo settings and connect the GitLab repo to Vercel. GitHub is out while that account is suspended.

vercel.json pins the framework and build command. output: "standalone" is switched off automatically when VERCEL is set, so the same commit builds for both targets.

The Actions runner on the Pi (forgejo-runner, label ubuntu-latest, container code.forgejo.org/forgejo/runner:12) is registered to this repo. It needs container.options: "--add-host=host.docker.internal:host-gateway" in its config, otherwise job containers cannot resolve the instance for checkout.

Raspberry Pi (self-hosted). Docker + Caddy in front of the standalone server; roughly 250 MB of RAM at rest.

# on the Pi
sudo git clone https://git.the424.com/bots/portfolio.git /opt/portfolio
cd /opt/portfolio
sudo cp deploy/portfolio.env.example .env.selfhost   # then edit
sudo docker compose build app                        # slow on a Pi: 10-20 min
sudo docker compose up -d
curl -s localhost/api/health

sudo bash deploy/install.sh does the same thing end to end and installs Docker first if it is missing. It is idempotent, so re-run it to update.

Details:

  • On a box that already terminates TLS (nginx-proxy-manager on the Pi), use the override: docker compose -f docker-compose.yml -f docker-compose.pi.yml up -d publishes the app on :8080 and leaves :80/:443 alone. Point an NPM proxy host at it and NPM issues the certificate.
  • Caddy gets automatic HTTPS as soon as SITE_ADDRESS=portfolio.example.com is set; without it Caddy serves plain HTTP on :80, which is what you want behind Cloudflare Tunnel or on a LAN.
  • Private repos at build time: put a token in deploy/forgejo_token and build with docker compose -f docker-compose.yml -f docker-compose.token.yml build. The token is mounted as a BuildKit secret, so it never lands in an image layer.
  • No port forwarding? docker compose --profile tunnel up -d with CLOUDFLARE_TUNNEL_TOKEN in the environment runs cloudflared instead.
  • Nightly rebuild from main: 17 4 * * * /opt/portfolio/deploy/refresh.sh >> /var/log/portfolio-refresh.log 2>&1
  • Not using Docker? deploy/portfolio.service runs the standalone server under systemd: build with npm run build, copy .next/standalone, .next/static and public to /opt/portfolio, install the unit, systemctl enable --now portfolio.

NEXT_PUBLIC_SITE_URL decides which origin ends up in the canonical tags, sitemap and RSS, so the Pi can serve a different hostname than the primary.