diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 4c214ea..94153cf 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -1,4 +1,4 @@ -name: Deploy to Vercel +name: Deploy to Cloudflare on: push: @@ -14,11 +14,16 @@ jobs: steps: - uses: actions/checkout@v4 - - - name: Deploy to Vercel - uses: amondnet/vercel-action@v25 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 with: - vercel-token: ${{ secrets.VERCEL_TOKEN }} - vercel-org-id: ${{ secrets.VERCEL_ORG_ID }} - vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }} - vercel-args: '--prod' + node-version: 22 + cache: "pnpm" + + - run: pnpm install --frozen-lockfile --strict-peer-dependencies + + - name: Deploy to Cloudflare Workers + run: pnpm cf:deploy + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} diff --git a/CLAUDE.md b/CLAUDE.md index 88e98e2..0d9ae0d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -13,7 +13,7 @@ A Next.js site that renders content from Notion as a CMS, using react-notion-x. - **Framework:** Next.js (Pages Router, SSR), React 19 - **CMS:** Notion via react-notion-x -- **Hosting:** Vercel (auto-deploy on push to `master`) +- **Hosting:** Cloudflare Workers via OpenNext (auto-deploy on push to `master`) - **Styling:** CSS Modules + global CSS custom properties (warm earth-tone design system) - **Fonts:** Fraunces (display), DM Sans (body) - **Package manager:** pnpm (Node >= 20) @@ -26,9 +26,33 @@ pnpm install # install deps (runs patch-package postinstall) pnpm dev # dev server at localhost:3000 pnpm build # production build pnpm start # serve production build -npx vercel --prod --yes # manual deploy to Vercel +pnpm cf:build # build OpenNext Cloudflare worker bundle +pnpm cf:preview # preview worker locally via miniflare +pnpm cf:deploy # manual deploy (requires CLOUDFLARE_API_TOKEN + CLOUDFLARE_ACCOUNT_ID in env) +pnpm deploy:local # manual deploy with secrets pulled from Bitwarden (see Deploying) ``` +## Deploying + +CI auto-deploys on push to `master` via `.github/workflows/deploy.yml` using the +`CLOUDFLARE_API_TOKEN` and `CLOUDFLARE_ACCOUNT_ID` GitHub Actions secrets. + +For a manual deploy from a local machine, secrets live in Bitwarden: + +- Item: `techempower cloudflare api` (secure note) +- `notes` field → `CLOUDFLARE_API_TOKEN` +- custom field `id` → `CLOUDFLARE_ACCOUNT_ID` + +Unlock the vault once per shell, then run the helper script: + +```bash +export BW_SESSION=$(bw unlock --raw) +pnpm deploy:local +``` + +`scripts/deploy.sh` reads both values from Bitwarden, exports them, and runs +`pnpm cf:deploy`. Requires `bw` and `jq` on PATH. + ## Key Files | Path | Purpose | diff --git a/open-next.config.ts b/open-next.config.ts index 18ca3d0..3f7fe37 100644 --- a/open-next.config.ts +++ b/open-next.config.ts @@ -1,6 +1,6 @@ import { defineCloudflareConfig } from '@opennextjs/cloudflare' -import kvIncrementalCache from '@opennextjs/cloudflare/overrides/incremental-cache/kv-incremental-cache' +import r2IncrementalCache from '@opennextjs/cloudflare/overrides/incremental-cache/r2-incremental-cache' export default defineCloudflareConfig({ - incrementalCache: kvIncrementalCache + incrementalCache: r2IncrementalCache }) diff --git a/package.json b/package.json index 9974766..1bf2628 100644 --- a/package.json +++ b/package.json @@ -16,10 +16,11 @@ "dev": "next dev", "build": "next build", "start": "next start", - "deploy": "vercel deploy", "cf:build": "opennextjs-cloudflare build", "cf:preview": "opennextjs-cloudflare preview", "cf:deploy": "opennextjs-cloudflare deploy", + "deploy": "opennextjs-cloudflare deploy", + "deploy:local": "bash scripts/deploy.sh", "deps:upgrade": "[ -z $GITHUB_ACTIONS ] && pnpm up -L notion-client notion-types notion-utils react-notion-x || echo 'Skipping deps:update on CI'", "deps:link": "[ -z $GITHUB_ACTIONS ] && run-s deps:link:* || echo 'Skipping deps:update on CI'", "deps:unlink": "[ -z $GITHUB_ACTIONS ] && pnpm add notion-client notion-types notion-utils react-notion-x || echo 'Skipping deps:update on CI'", diff --git a/readme.md b/readme.md index 5b450b1..272da07 100644 --- a/readme.md +++ b/readme.md @@ -29,7 +29,8 @@ This repository powers [techempower.org](https://techempower.org) — a Next.js |-------|-----------| | Framework | [Next.js](https://nextjs.org/) (Pages Router, SSR) | | CMS | [Notion](https://notion.so) via [react-notion-x](https://github.com/NotionX/react-notion-x) | -| Hosting | [Vercel](https://vercel.com) | +| Hosting | [Cloudflare Workers](https://workers.cloudflare.com/) via [OpenNext](https://opennext.js.org/cloudflare) | +| Incremental cache | [Cloudflare R2](https://developers.cloudflare.com/r2/) (bucket: `techempower-cache`) | | Styling | CSS Modules + global CSS custom properties | | Fonts | Fraunces (display), DM Sans (body) | | Analytics | Google Analytics, Fathom (optional), PostHog (optional) | @@ -104,25 +105,56 @@ site.config.ts Notion page IDs, navigation, site metadata | Command | Description | |---------|-------------| | `pnpm dev` | Start local dev server | -| `pnpm build` | Production build | +| `pnpm build` | Production build (Next.js) | | `pnpm start` | Serve production build locally | | `pnpm format` | Format code with Prettier | -| `pnpm deploy` | Deploy to Vercel | +| `pnpm cf:build` | Build the OpenNext Cloudflare worker bundle | +| `pnpm cf:preview` | Preview the worker locally via miniflare | +| `pnpm cf:deploy` | Deploy to Cloudflare Workers (requires env secrets) | +| `pnpm deploy:local` | Deploy with secrets pulled from Bitwarden (see [Deployment](#deployment)) | ## Deployment -The site deploys to **Vercel**. Push to `master` to trigger automatic deployment. +The site deploys to **Cloudflare Workers** via [OpenNext](https://opennext.js.org/cloudflare). Push to `master` to trigger automatic deployment through [`.github/workflows/deploy.yml`](./.github/workflows/deploy.yml). + +### GitHub Actions (auto-deploy) + +Requires two repository secrets: + +- `CLOUDFLARE_API_TOKEN` — scoped to `Workers Scripts: Edit` (account), `Workers R2 Storage Bucket Item Write` on `techempower-cache`, and `Workers Routes: Edit` on the `techempower.org` zone. +- `CLOUDFLARE_ACCOUNT_ID` + +### Manual deploy from a developer machine + +Secrets live in a Bitwarden secure note named `techempower cloudflare api`: + +- `notes` field → `CLOUDFLARE_API_TOKEN` +- custom field `id` → `CLOUDFLARE_ACCOUNT_ID` + +Unlock the vault once per shell, then run: + +```bash +export BW_SESSION=$(bw unlock --raw) +pnpm deploy:local +``` + +[`scripts/deploy.sh`](./scripts/deploy.sh) reads both values from Bitwarden, exports them, and runs `pnpm cf:deploy`. Requires `bw` and `jq` on PATH. + +### Prerequisite: R2 bucket + +The incremental cache binding points at an R2 bucket that must exist in the target account: -Manual deploy: ```bash -npx vercel --prod --yes +npx wrangler r2 bucket create techempower-cache ``` -SSR pages include CDN caching headers (`s-maxage=3600, stale-while-revalidate=86400`) for fast subsequent loads. +### Caching + +SSR pages include CDN caching headers (`s-maxage=3600, stale-while-revalidate=86400`). The Next.js incremental cache is backed by R2 (see [`open-next.config.ts`](./open-next.config.ts) and the `r2_buckets` binding in [`wrangler.jsonc`](./wrangler.jsonc)). ## Environment Variables -Set these in your Vercel project settings or in a local `.env` file: +App-level variables (set in a local `.env` file for development, or via `wrangler secret put` / Cloudflare dashboard for production): | Variable | Required | Description | |----------|----------|-------------| @@ -132,6 +164,13 @@ Set these in your Vercel project settings or in a local `.env` file: | `REDIS_HOST` | No | Redis host for preview image caching | | `REDIS_PASSWORD` | No | Redis password | +Deploy-time variables (GitHub Actions secrets / local shell): + +| Variable | Required | Description | +|----------|----------|-------------| +| `CLOUDFLARE_API_TOKEN` | Yes | Token with Workers Scripts Edit + R2 bucket write + Workers Routes Edit | +| `CLOUDFLARE_ACCOUNT_ID` | Yes | Cloudflare account ID | + ## Contributing See [contributing.md](./contributing.md) for development setup and guidelines. diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..5f04d94 --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# Deploy to Cloudflare Workers using Cloudflare secrets pulled from Bitwarden. +# +# Prereqs: +# - Bitwarden CLI (`bw`) installed and logged in. +# - `jq` installed. +# - Vault unlocked: `export BW_SESSION=$(bw unlock --raw)` +# - Bitwarden item "techempower cloudflare api" (secure note) with: +# - notes -> CLOUDFLARE_API_TOKEN +# - custom "id" -> CLOUDFLARE_ACCOUNT_ID + +set -euo pipefail + +ITEM_NAME="techempower cloudflare api" + +if ! command -v bw >/dev/null 2>&1; then + echo "error: bw (Bitwarden CLI) not found on PATH" >&2 + exit 1 +fi + +if ! command -v jq >/dev/null 2>&1; then + echo "error: jq not found on PATH" >&2 + exit 1 +fi + +bw_status="$(bw status 2>/dev/null || true)" +if ! printf '%s' "$bw_status" | grep -q '"status":"unlocked"'; then + echo "error: Bitwarden vault is locked (or bw is not logged in)." >&2 + echo " run: export BW_SESSION=\$(bw unlock --raw)" >&2 + exit 1 +fi + +item_json="$(bw get item "$ITEM_NAME")" +CLOUDFLARE_API_TOKEN="$(printf '%s' "$item_json" | jq -r '.notes // ""')" +CLOUDFLARE_ACCOUNT_ID="$(printf '%s' "$item_json" | jq -r '.fields[]? | select(.name=="id") | .value')" + +if [[ -z "$CLOUDFLARE_API_TOKEN" || -z "$CLOUDFLARE_ACCOUNT_ID" ]]; then + echo "error: could not read CLOUDFLARE_API_TOKEN (notes) or CLOUDFLARE_ACCOUNT_ID (custom field 'id')" >&2 + echo " from Bitwarden item '$ITEM_NAME'" >&2 + exit 1 +fi + +export CLOUDFLARE_API_TOKEN +export CLOUDFLARE_ACCOUNT_ID + +exec pnpm cf:deploy diff --git a/wrangler.jsonc b/wrangler.jsonc index 63f136a..604ffdf 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -16,10 +16,12 @@ "observability": { "enabled": true }, - "kv_namespaces": [ + "r2_buckets": [ { - "binding": "NEXT_INC_CACHE_KV", - "id": "77055535c51e442193e5dad69ceb5075" + // Bucket must be pre-created in the Cloudflare account before deploy: + // npx wrangler r2 bucket create techempower-cache + "binding": "NEXT_INC_CACHE_R2_BUCKET", + "bucket_name": "techempower-cache" } ] }