Skip to content

Latest commit

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date

Repository files navigation

Personal Website - Daniel Heene

This repository contains the source code for my personal website: daniel.heene.io.

For AI coding agents: see AGENTS.md for coding conventions, architecture notes, and known security/style guardrails before making changes.

Tech Stack

Requirements

  • Node.js: ^26.0.0
  • pnpm: ^11.0.0
  • Docker: For running local database, cache, and storage services.

Setup & Local Development

1. Environment Configuration

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 config

doppler 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 runpnpm 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 --check reports whether .env.local is 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.

2. Start Services

Launch the infrastructure (MongoDB, Redis, and Minio) using Docker Compose:

docker compose up -d

3. Install Dependencies

pnpm install

4. Generate Payload Artifacts

Payload requires generated TypeScript types and an import map for the admin panel:

pnpm generate

5. Run the Application

Start the development server:

pnpm dev

Environment Variables

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.

Core

VariablePurpose
DATABASE_URLMongoDB connection string.
REDIS_URLRedis connection URL (KV adapter and cache handler).
SERVER_URLPublic URL of the server. Inlined into the client bundle.
SERVER_HOSTHost/port used for server-side URL construction.
PAYLOAD_SECRETEncrypts Payload JWT tokens.
PREVIEW_SECRETAuthenticates Next.js/Payload draft previews.
CRON_SECRETReserved for cron tasks. Declared but not yet enforced by any route (see AGENTS.md).

Status page

VariablePurpose
STATUS_PAGE_URLStatus page link. Inlined into the client bundle.
STATUS_PAGE_HEARTBEAT_URLHeartbeat endpoint pinged server-side.

Storage (Minio in local dev)

S3_BUCKET, S3_REGION, S3_ENDPOINT, S3_ACCESS_KEY, S3_SECRET_KEY.

Analytics, email, and third-party APIs

VariablePurpose
NEXT_PUBLIC_UMAMI_URL, NEXT_PUBLIC_UMAMI_SITE_IDUmami rewrite target and site ID (a UUID).
UMAMI_USERNAME, UMAMI_PASSWORDCredentials for the server-side Umami stats query.
USESEND_URL, USESEND_API_KEY, USESEND_DEFAULT_FROM_ADDRESS, USESEND_DEFAULT_FROM_NAMEUseSend email provider.
OPENAI_API_KEY, ANTHROPIC_API_KEYGenerated alt text and meta descriptions.
MAPBOX_API_KEYAddress and coordinate lookups.
UNSPLASH_ACCESS_KEYStock photo search & import in the media library admin. Optional — the feature is hidden if unset.

Sentry (all optional)

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 (all optional)

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_URL and SENTRY_DSN are listed in next.config.ts's env: block, which inlines them into the compiled bundle. On top of that, cacheComponents: true gives nearly every route a shell rendered at build time, so a server-side process.env read 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.

Production Build

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 start

Notes:

  • Three values are inlined at build time.SERVER_URL, STATUS_PAGE_URL and SENTRY_DSN are baked into the client bundle and captured in the prerendered shell (cacheComponents: true gives 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 development config points DATABASE_URL and REDIS_URL at hosts on the tailnet, so the build only resolves them from a machine already on the tailnet.
  • docker compose up -d is only the local infrastructure (Mongo, Redis, rustfs). The app is not a compose service — it runs via pnpm dev.

Available Scripts

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.local from the active Doppler config (--check to 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: Runs generate:types and generate:importmap in parallel.
  • pnpm payload: Wrapper for Payload CLI.
  • pnpm migrate: Runs database migrations.
  • pnpm ci: Sequence for CI/CD (migration + build).
  • pnpm lint: Runs biome check (lint + format check) for code quality.
  • pnpm format: Runs biome format --write to auto-fix formatting.
  • pnpm storybook / pnpm dev:storybook: Runs Storybook for isolated component development.

Project Structure

.
├── 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

Entrypoints

  • Next.js App Router under app/ (served via pnpm dev / pnpm start).
  • Payload CMS is configured in payload.config.ts (repo root) and integrated via withPayload in next.config.ts.
  • File uploads are handled by Payload collections with the S3 storage plugin configured in payload.config.ts.

Testing

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.ts next to the code under test (config: vitest.config.ts, shared mocks and TZ=UTC in vitest.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.test is 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:e2e starts (or reuses) the dev server automatically. The Docker flow (scripts/e2e-docker.sh) expects the dev server already running on the host and uses the mcr.microsoft.com/playwright image — its tag must always match the @playwright/test version in package.json; bump them together.

Commits & Git Hooks

Hooks are installed by Husky via the prepare script, so pnpm install sets them up automatically.

HookRuns
pre-commitlint-stagedbiome check --write on staged files only
commit-msgcommitlint 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.

Error Tracking (Sentry)

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.

VariablePurpose
SENTRY_DSNEnables the SDK. Everything below is ignored without it.
SENTRY_TRACES_SAMPLE_RATETrace sampling, 01. Defaults to 1 in dev, 0.1 in production.
SENTRY_ENVIRONMENT / SENTRY_RELEASEOverride the reported environment and release.
SENTRY_AUTH_TOKEN + SENTRY_ORG + SENTRY_PROJECTSource-map upload during build. All three required; skipped otherwise.
NEXT_PUBLIC_SENTRY_REPLAY_RATE / ..._ERROR_RATESession replay, off by default.

Notes:

  • /api/sse and /api/health/* are excluded from tracing — they are polled or long-lived and would dominate the quota.
  • Sentry requests are proxied through /monitoring so ad blockers cannot drop them.
  • Source maps are deleted after upload, so they are never served publicly.

Features

  • Media Optimization: Images are stored in S3 and automatically generate alt text and blurDataURL on upload using sharp.
  • SVG Optimization: Optimize SVGs for logos using svgo in the admin UI. Note: this is a client-side editor convenience only, not a server-side sanitization boundary — see AGENTS.md for 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.

Data Seeding

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 again

Article 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.

CI / Deployment

A pull request against main or develop runs one pipelined quality gate (.github/workflows/ci.yml), each stage gating the next:

  1. Commit Messages — commitlint over the PR's commit range.
  2. Unit Testsvitest run --coverage plus deps:lint. The suite mocks payload and stubs its own environment in vitest.setup.ts, so it needs no database, no tailnet and no secrets.
  3. Build app + worker images / Build storybook image (run in parallel once tests pass) — builds all three images (Dockerfile's app, worker, and storybook targets) and pushes them to ghcr.io tagged sha-<PR head SHA>. The app/worker build needs a reachable database, since generateStaticParams() calls payload.find() in several routes — the runner joins the tailnet via tailscale/github-action for 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 → retagged edge.
  • Merge to main → once .github/workflows/release.yml's semantic-release run computes a new version, retagged latest and vX.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.

Code Quality & Security Notes

  • Linting/formatting is enforced by Biome (biome.json), not ESLint/Prettier — see AGENTS.md for the full style conventions.
  • pnpm lint does not currently pass cleanly on main (pre-existing formatting and lint diagnostics); avoid introducing new issues when touching a file.
  • See AGENTS.md for a summary of known security guardrails (open queryPresets access, unauthenticated SSE channel subscription, unsanitized raw SVG rendering, unused CRON_SECRET) to keep in mind when working in related areas.

Legacy Code

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.

About

codebase for my personal website / portfolio

Topics

Resources

Stars

3 stars

Watchers

1 watching

Forks

Releases

Packages

Used by

Contributors

Languages