This repository contains the source code for my personal website: daniel.heene.io.
For AI coding agents: see
AGENTS.mdfor coding conventions, architecture notes, and known security/style guardrails before making changes.
- Framework: Next.js 16 (App Router, React 19)
- CMS: Payload CMS 3.x
- Database: MongoDB (via Mongoose)
- Cache/KV: Redis
- Storage: S3-compatible storage (local Minio in development)
- Styling: Tailwind CSS 4
- Analytics: Umami (Optional)
- Email: UseSend (Optional)
- Node.js:
^26.0.0 - pnpm:
^11.0.0 - Docker: For running local database, cache, and storage services.
Configuration and secrets are managed in Doppler. Install the CLI,
link this directory to the project, then write the config out to .env.local:
brew install dopplerhq/cli/doppler # see docs.doppler.com/docs/install-cli for other platforms
doppler login
doppler setup --project website --config <your-config>
pnpm load-env # writes .env.local from the active configdoppler setup stores the project and config against this directory in ~/.doppler, so
it is a one-time step per clone. There is no checked-in default: configs differ per
developer (development_personal and the like), and pinning one in the repo made it easy
to build against the wrong database without noticing. doppler configure shows what the
current directory resolves to.
pnpm load-env is the only thing that talks to Doppler. Next loads .env.local
automatically, so no package script wraps doppler run — pnpm dev, pnpm payload and
the rest just work. Re-run it after changing anything in Doppler, or after switching
configs with doppler setup --config <name>; nothing detects drift on its own.
pnpm load-env --checkreports whether.env.localis current and exits non-zero if not, without writing.- The file is gitignored and written owner-only (
0600) — it holds every secret the project uses. It is never generated as a side effect of another script. - To inspect what will be written without touching the filesystem, run
doppler secrets.
Launch the infrastructure (MongoDB, Redis, and Minio) using Docker Compose:
docker compose up -dpnpm installPayload requires generated TypeScript types and an import map for the admin panel:
pnpm generateStart the development server:
pnpm dev- Frontend: http://localhost:3000
- Admin Panel: http://localhost:3000/admin
apps/web/src/types/environment.ts is the source of truth: it declares a Zod schema that
next.config.ts validates at load time, so the process exits immediately if anything
required is missing or malformed. Doppler stores the values; the list below explains them.
Everything is required unless marked optional.
| Variable | Purpose |
|---|---|
DATABASE_URL | MongoDB connection string. |
REDIS_URL | Redis connection URL (KV adapter and cache handler). |
SERVER_URL | Public URL of the server. Inlined into the client bundle. |
SERVER_HOST | Host/port used for server-side URL construction. |
PAYLOAD_SECRET | Encrypts Payload JWT tokens. |
PREVIEW_SECRET | Authenticates Next.js/Payload draft previews. |
CRON_SECRET | Reserved for cron tasks. Declared but not yet enforced by any route (see AGENTS.md). |
| Variable | Purpose |
|---|---|
STATUS_PAGE_URL | Status page link. Inlined into the client bundle. |
STATUS_PAGE_HEARTBEAT_URL | Heartbeat endpoint pinged server-side. |
S3_BUCKET, S3_REGION, S3_ENDPOINT, S3_ACCESS_KEY, S3_SECRET_KEY.
| Variable | Purpose |
|---|---|
NEXT_PUBLIC_UMAMI_URL, NEXT_PUBLIC_UMAMI_SITE_ID | Umami rewrite target and site ID (a UUID). |
UMAMI_USERNAME, UMAMI_PASSWORD | Credentials for the server-side Umami stats query. |
USESEND_URL, USESEND_API_KEY, USESEND_DEFAULT_FROM_ADDRESS, USESEND_DEFAULT_FROM_NAME | UseSend email provider. |
OPENAI_API_KEY, ANTHROPIC_API_KEY | Generated alt text and meta descriptions. |
MAPBOX_API_KEY | Address and coordinate lookups. |
UNSPLASH_ACCESS_KEY | Stock photo search & import in the media library admin. Optional — the feature is hidden if unset. |
With no DSN the SDK is never initialised and the app runs unchanged. SENTRY_DSN is public
by design and is inlined into the client bundle. SENTRY_ENVIRONMENT, SENTRY_RELEASE and
SENTRY_TRACES_SAMPLE_RATE tune reporting; SENTRY_AUTH_TOKEN, SENTRY_ORG and
SENTRY_PROJECT are only needed to upload source maps during a build;
NEXT_PUBLIC_SENTRY_REPLAY_RATE and NEXT_PUBLIC_SENTRY_REPLAY_ERROR_RATE control replay
sampling.
CLOUDFLARE_TUNNEL_HOST, CLOUDFLARE_TUNNEL_URL and CLOUDFLARE_TUNNEL_TOKEN are only
read when starting the dev server with --tunnel.
Fixed at build time.
SERVER_URL,STATUS_PAGE_URLandSENTRY_DSNare listed innext.config.ts'senv:block, which inlines them into the compiled bundle. On top of that,cacheComponents: truegives nearly every route a shell rendered at build time, so a server-sideprocess.envread is captured into that shell and served from cache — moving the read further up the tree does not change this. All three must therefore be correct when the app is built, which makes a build specific to one environment. All three are public values, so nothing secret is baked in.
Dokploy builds the app from source on the deployment server; there is no image to
build or push. The build needs a reachable database because generateStaticParams()
calls payload.find(), and a Redis URL because the KV adapter is constructed while
payload.config.ts loads.
To reproduce a production build locally:
pnpm build
pnpm startNotes:
- Three values are inlined at build time.
SERVER_URL,STATUS_PAGE_URLandSENTRY_DSNare baked into the client bundle and captured in the prerendered shell (cacheComponents: truegives nearly every route a build-time shell), so passing them at run time only satisfies the schema check — it does not change what is served. Build with the config you intend to run. Every secret and all server-only config is runtime, so a deployment can be repointed at a different database, cache, bucket or mail provider without rebuilding. - A production build validates only the build-time subset of the schema; the full schema is validated at boot, so a missing runtime variable still fails fast — at the point where it can be supplied.
- Private hosts need Tailscale. The
developmentconfig pointsDATABASE_URLandREDIS_URLat hosts on the tailnet, so the build only resolves them from a machine already on the tailnet. docker compose up -dis only the local infrastructure (Mongo, Redis, rustfs). The app is not a compose service — it runs viapnpm dev.
No script wraps doppler run. Locally the environment comes from .env.local, which
Next loads on its own — see Environment Configuration for
how to generate it. On the deployment server Dokploy supplies the environment directly.
The one script that does talk to Doppler is pnpm load-env, which writes that file.
pnpm load-env: Writes.env.localfrom the active Doppler config (--checkto report drift without writing).pnpm dev: Starts the Next.js development server (and Storybook, in parallel).pnpm build: Builds the application for production.pnpm start: Starts the production server.pnpm generate: Runsgenerate:typesandgenerate:importmapin parallel.pnpm payload: Wrapper for Payload CLI.pnpm migrate: Runs database migrations.pnpm ci: Sequence for CI/CD (migration + build).pnpm lint: Runsbiome check(lint + format check) for code quality.pnpm format: Runsbiome format --writeto auto-fix formatting.pnpm storybook/pnpm dev:storybook: Runs Storybook for isolated component development.
.
├── app/ # Next.js App Router
│ ├── (frontend)/ # Public site routes, incl. api/ (preview, sse, heartbeat)
│ └── (payload)/ # Payload admin panel routes
├── src/ # Application Source
│ ├── access/ # Payload Access Control functions
│ ├── blocks/ # Reusable Payload Blocks (resume sections, content blocks)
│ ├── collections/ # Payload Collections (Media, Pages, BlogPosts, Resume*, Users, etc.)
│ ├── components/ # React Components
│ ├── contexts/ # React Context providers
│ ├── fields/ # Custom/reusable Payload Field factories
│ ├── globals/ # Payload Globals (SiteSettings, PDFGeneratorSettings, etc.)
│ ├── hooks/ # React hooks
│ ├── jobs-queue/ # Payload Jobs Queue tasks/workflows
│ ├── lib/ # Framework-agnostic utilities (Redis handler, caching, etc.)
│ ├── pdf/ # PDF generation (resume export)
│ ├── styles/ # CSS and Tailwind styles
│ ├── types/ # Shared/generated TypeScript types (incl. generated payload.ts)
│ └── widgets/ # Payload admin dashboard widgets
├── public/ # Static Assets
├── scripts/ # Standalone Node scripts (e.g. dev tunnel)
├── payload.config.ts # Payload CMS configuration (root-level, not under src/)
├── docker-compose.yml # Local Infrastructure
└── next.config.ts # Next.js Configuration
- Next.js App Router under
app/(served viapnpm dev/pnpm start). - Payload CMS is configured in
payload.config.ts(repo root) and integrated viawithPayloadinnext.config.ts. - File uploads are handled by Payload collections with the S3 storage plugin configured in
payload.config.ts.
Unit tests run with Vitest, end-to-end tests with Playwright (Chromium).
- Unit tests:
pnpm test(watch mode:pnpm test:watch, coverage:pnpm test:coverage) - E2E tests:
pnpm test:e2e(interactive UI:pnpm test:e2e:ui) - E2E in Docker Chromium:
pnpm test:e2e:docker
Conventions:
- Unit tests are co-located as
*.test.tsnext to the code under test (config:vitest.config.ts, shared mocks andTZ=UTCinvitest.setup.ts). - E2E specs live in
e2e/*.spec.ts(config:playwright.config.ts). They are smoke tests only — the site is CMS-driven, so they assert structural health, not content.
E2E prerequisites:
docker compose up -d(MongoDB, Redis and S3 storage must be reachable)..env.testis committed with dummy, format-valid values so the app can boot. Flows backed by real external services do not work with it: AI generation (OpenAI/Anthropic), Mapbox geocoding, email (UseSend), Umami analytics and the status-page heartbeat.pnpm test:e2estarts (or reuses) the dev server automatically. The Docker flow (scripts/e2e-docker.sh) expects the dev server already running on the host and uses themcr.microsoft.com/playwrightimage — its tag must always match the@playwright/testversion inpackage.json; bump them together.
Hooks are installed by Husky via the prepare script, so pnpm install sets
them up automatically.
| Hook | Runs |
|---|---|
pre-commit | lint-staged → biome check --write on staged files only |
commit-msg | commitlint against Conventional Commits |
Formatting fixes are re-staged automatically, so a commit that only needed formatting still goes through. Only staged files are touched — pre-existing issues elsewhere never block an unrelated commit.
Commit messages follow Conventional Commits:
feat(admin): add Iconify icon picker field
fix: repair the Docker build for the flat repo layout
chore!: drop Node 20 support # `!` marks a breaking change
Scopes are deliberately unrestricted — see commitlint.config.mjs.
Both hooks can be skipped with git commit --no-verify for genuine
emergencies; the same commitlint check runs on pull requests in CI, so a
bypassed message still has to be fixed before merge.
Sentry is wired for errors, Web Vitals, logs and traces, but stays completely
inert until SENTRY_DSN is set — no DSN means Sentry.init is never called,
so local development is unaffected.
| Variable | Purpose |
|---|---|
SENTRY_DSN | Enables the SDK. Everything below is ignored without it. |
SENTRY_TRACES_SAMPLE_RATE | Trace sampling, 0–1. Defaults to 1 in dev, 0.1 in production. |
SENTRY_ENVIRONMENT / SENTRY_RELEASE | Override the reported environment and release. |
SENTRY_AUTH_TOKEN + SENTRY_ORG + SENTRY_PROJECT | Source-map upload during build. All three required; skipped otherwise. |
NEXT_PUBLIC_SENTRY_REPLAY_RATE / ..._ERROR_RATE | Session replay, off by default. |
Notes:
/api/sseand/api/health/*are excluded from tracing — they are polled or long-lived and would dominate the quota.- Sentry requests are proxied through
/monitoringso ad blockers cannot drop them. - Source maps are deleted after upload, so they are never served publicly.
- Media Optimization: Images are stored in S3 and automatically generate
alttext andblurDataURLon upload usingsharp. - SVG Optimization: Optimize SVGs for logos using
svgoin the admin UI. Note: this is a client-side editor convenience only, not a server-side sanitization boundary — seeAGENTS.mdfor the related security note. - Localization: Full support for English (
en) and German (de) with localized admin panel and content. - Modern Styling: Powered by Tailwind CSS v4.
- Live Preview & Server-Sent Events: Draft/live preview via
app/(frontend)/api/preview, and a Redis pub/sub-backed SSE endpoint (app/(frontend)/api/sse) for real-time status updates.
Seed blog topics and posts for local testing (idempotent, matched by slug):
pnpm seed:blog # create 6 topics + 30 posts (with downloaded images)
pnpm seed:blog:clean # remove them againArticle structure and prose are randomized per post title with a seeded PRNG, so
reruns produce identical output while each post differs. Images are downloaded
from picsum.photos (keyless) and uploaded into the images collection; the AI
alt-text hook is skipped during seeding.
Note: payload run only forwards CLI arguments after a -- separator — see the
seed:blog:clean script.
- TODO: If seeding is needed, add a dedicated script or route and document usage here.
A pull request against main or develop runs one pipelined quality gate
(.github/workflows/ci.yml), each stage gating the next:
- Commit Messages — commitlint over the PR's commit range.
- Unit Tests —
vitest run --coverageplusdeps:lint. The suite mockspayloadand stubs its own environment invitest.setup.ts, so it needs no database, no tailnet and no secrets. - Build app + worker images / Build storybook image (run in parallel once tests
pass) — builds all three images (
Dockerfile'sapp,worker, andstorybooktargets) and pushes them toghcr.iotaggedsha-<PR head SHA>. The app/worker build needs a reachable database, sincegenerateStaticParams()callspayload.find()in several routes — the runner joins the tailnet viatailscale/github-actionfor that step. Storybook's build touches no database, so it skips Tailscale entirely.
These four checks are required status checks on main — a PR cannot merge unless all
four pass, so nothing gets built into an image (and nothing gets merged) unless it built
and tested clean first.
On merge (.github/workflows/promote.yml), the images already built and pushed for that
PR's head SHA are retagged, not rebuilt — docker buildx imagetools create is a
registry metadata operation:
- Merge to
develop→ retaggededge. - Merge to
main→ once.github/workflows/release.yml's semantic-release run computes a new version, retaggedlatestandvX.Y.Z.
Dokploy deploys from the resulting ghcr.io images rather than building from source.
Secrets and non-secret config reach both CI and the server from Doppler: the Doppler
GitHub App syncs into GitHub environments (Production for main, Development
otherwise), and a PR's Docker build step uses whichever environment matches its base
branch.
- Linting/formatting is enforced by Biome (
biome.json), not ESLint/Prettier — seeAGENTS.mdfor the full style conventions. pnpm lintdoes not currently pass cleanly onmain(pre-existing formatting and lint diagnostics); avoid introducing new issues when touching a file.- See
AGENTS.mdfor a summary of known security guardrails (openqueryPresetsaccess, unauthenticated SSE channel subscription, unsanitized raw SVG rendering, unusedCRON_SECRET) to keep in mind when working in related areas.
The following code of my previous websites is no longer maintained, but a dump of their code bases can still be found under the following tags: website-v2 | website-v1.