diff --git a/.env.selfhost.example b/.env.selfhost.example new file mode 100644 index 0000000..64d4eef --- /dev/null +++ b/.env.selfhost.example @@ -0,0 +1,32 @@ +# OpenSEO Cloudflare self-host. Walkthrough: docs/SELF_HOSTING_CLOUDFLARE_ALCHEMY.md + +# ---------- Required ---------- + +# Get one at https://dataforseo.com — see docs/DATAFORSEO_API_KEY.md +DATAFORSEO_API_KEY= + +# Who may sign in through Cloudflare Access, comma-separated +ACCESS_ALLOWED_EMAILS= + +# ---------- Optional — uncomment to use ---------- + +# Google Search Console (all three together) — docs/SELF_HOSTING_GOOGLE_SEARCH_CONSOLE.md +# GOOGLE_CLIENT_ID= +# GOOGLE_CLIENT_SECRET= +# BETTER_AUTH_SECRET= + +# SAM, the in-app agent (hidden if unset) +# OPENROUTER_API_KEY= +# OPENROUTER_MODEL= + +# Your own PostHog product analytics +# POSTHOG_PUBLIC_KEY= +# POSTHOG_HOST= + +# Opt out of anonymized telemetry — docs/SELF_HOSTING_CLOUDFLARE_OPERATIONS.md +# OPENSEO_TELEMETRY_DISABLED=1 + +# Bring your own Cloudflare Access application instead of the auto-provisioned +# one (ACCESS_ALLOWED_EMAILS is then ignored) +# TEAM_DOMAIN=https://your-team.cloudflareaccess.com +# POLICY_AUD=your-access-application-audience-tag diff --git a/.gitignore b/.gitignore index 60403d9..b589d77 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ dist-sourcemaps/ !.env.example !.env.preview.example !.env.production.example +!.env.selfhost.example .vercel .output .nitro diff --git a/alchemy.run.ts b/alchemy.run.ts index d95a6e1..8a6f722 100644 --- a/alchemy.run.ts +++ b/alchemy.run.ts @@ -1,13 +1,18 @@ import * as Alchemy from "alchemy"; import * as Cloudflare from "alchemy/Cloudflare"; +import * as CfWorkers from "@distilled.cloud/cloudflare/workers"; +import * as ZeroTrust from "@distilled.cloud/cloudflare/zero-trust"; import * as Config from "effect/Config"; +import * as Console from "effect/Console"; import * as Effect from "effect/Effect"; import { Redacted } from "effect"; import { unstable_readConfig } from "wrangler"; import { z } from "zod"; import { + emailAccessGate, HOSTED_PROD_STAGE, readWorkersSubdomain, + requireAllowedEmails, workerName, } from "./alchemy.access.ts"; @@ -16,20 +21,20 @@ import { // boundary depends on. The shell copy in .github/workflows/pr-preview.yml // must be kept in sync by hand. -// Alchemy v2 stack for SaaS deployments — previews and prod. Stage semantics, -// security model, and credentials are documented once in -// docs/PREVIEW_DEPLOYMENTS.md. +// Alchemy v2 stack for SaaS deployments — previews, prod, and Cloudflare +// self-hosting. Stage semantics, security model, and credentials are +// documented once in docs/PREVIEW_DEPLOYMENTS.md. // // - Any stage except "hosted-prod": fresh stage-suffixed resources. Previews -// deploy via `pnpm deploy:preview --stage `. +// deploy via `pnpm deploy:preview --stage `; self-hosters via +// `pnpm deploy:selfhost` (stage "selfhost", no flag to pass). // - Stage "hosted-prod": names the EXISTING openseo.so production resources // so `--adopt` imports them. Deploy via `pnpm deploy:postgres` (--adopt and // the stage baked in). // -// Self-hosting still deploys through wrangler (wrangler.jsonc); an -// alchemy-based self-host path is a planned fast-follow. Local dev and Docker -// self-host do NOT use this stack (wrangler.jsonc + @cloudflare/vite-plugin). -// This stack deploys the PREBUILT `vite build` output — Alchemy never runs Vite. +// Local dev and Docker self-host do NOT use this stack (wrangler.jsonc + +// @cloudflare/vite-plugin). This stack deploys the PREBUILT `vite build` +// output — Alchemy never runs Vite. // The worker's runtime contract — compatibility date/flags, crons, // observability, placement, DO/workflow classes — has one source of truth: @@ -87,6 +92,21 @@ const makeResources = (stage: string) => { }).pipe(keep), R2: Cloudflare.R2.Bucket("R2", { name: prod ? PROD_NAMES.r2 : `open-seo-r2-${stage}`, + // Expire cached DataForSEO responses. Prod's lifecycle rules are + // dashboard-managed; its props stay omitted so alchemy leaves them be. + ...(prod + ? {} + : { + lifecycleRules: [ + { + id: "dataforseo-cache-expiry", + prefix: "dataforseo-cache/", + deleteObjectsTransition: { + condition: { type: "Age", maxAge: 7 * 24 * 60 * 60 }, + }, + }, + ], + }), }).pipe(keep), KV: Cloudflare.KV.Namespace("KV", { title: prod ? PROD_NAMES.kv : `open-seo-kv-${stage}`, @@ -135,6 +155,106 @@ const optionalVar = (name: string) => const optionalSecret = (name: string) => Config.redacted(name).pipe(Config.withDefault(Redacted.make(""))); +const accessScopeHint = + " (if this is a permissions error, re-run `pnpm alchemy login --configure`, answer yes to “Customize OAuth scopes?”, and select access:write alongside the defaults)"; + +/** + * Self-host auth (AUTH_MODE=cloudflare_access): derive the Access values + * instead of making the user copy them out of the dashboard. TEAM_DOMAIN is + * the account's Zero Trust team domain (one API read; the team is created — + * named after the workers.dev subdomain — if the account has none); + * POLICY_AUD is the audience tag of an alchemy-provisioned Access + * application whose allow-policy comes from ACCESS_ALLOWED_EMAILS. Explicit + * env values always win, so a hand-managed Access application keeps + * working — set both TEAM_DOMAIN and POLICY_AUD and nothing here provisions. + */ +const resolveSelfHostAccess = ( + stage: string, + provision: boolean, + workersSubdomain: string, +) => + Effect.gen(function* () { + let teamDomain = yield* optionalVar("TEAM_DOMAIN"); + let policyAud: Alchemy.Input = yield* optionalVar("POLICY_AUD"); + if (!provision || (teamDomain && policyAud)) { + return { teamDomain, policyAud }; + } + const { accountId } = yield* yield* Cloudflare.CloudflareEnvironment; + + // The workers.dev subdomain names both the Access application's hostname + // (which must exist before the Worker resource does) and an auto-created + // Zero Trust team; it is deterministic from the account. + let subdomain = workersSubdomain; + if (!subdomain) { + const observed = yield* CfWorkers.getSubdomain({ accountId }).pipe( + Effect.catch((error) => + Effect.die( + new Error( + `Could not read the workers.dev subdomain: ${String(error)}${accessScopeHint}`, + ), + ), + ), + ); + subdomain = `${observed.subdomain}.workers.dev`; + } + + if (!teamDomain) { + const organization = yield* ZeroTrust.listOrganizationsForAccount({ + accountId, + }).pipe( + Effect.catchTag("OrganizationNotFound", () => Effect.succeed(null)), + Effect.catch((error) => + Effect.die( + new Error( + `Could not read the Zero Trust organization: ${String(error)}${accessScopeHint}`, + ), + ), + ), + ); + if (organization?.authDomain) { + teamDomain = `https://${organization.authDomain}`; + } else { + // Fresh account with no Zero Trust team: create one, named after the + // workers.dev subdomain — both are globally unique account handles. + const teamName = subdomain.replace(/\.workers\.dev$/, ""); + yield* ZeroTrust.createOrganizationForAccount({ + accountId, + name: teamName, + authDomain: `${teamName}.cloudflareaccess.com`, + }).pipe( + Effect.catch((error) => + Effect.die( + new Error( + `Could not create the Zero Trust team "${teamName}": ${String(error)}${accessScopeHint}. You can also create one by hand — open https://one.dash.cloudflare.com once to pick a team name (free plan is fine), then redeploy.`, + ), + ), + ), + ); + yield* Console.log( + `Created the Zero Trust team "${teamName}" (${teamName}.cloudflareaccess.com) — its login page is where Cloudflare Access sends users to sign in.`, + ); + teamDomain = `https://${teamName}.cloudflareaccess.com`; + } + } + + if (!policyAud) { + const allowedEmails = yield* requireAllowedEmails( + "Set ACCESS_ALLOWED_EMAILS to the comma-separated emails allowed through Cloudflare Access — or set TEAM_DOMAIN and POLICY_AUD to manage the Access application yourself.", + ); + const application = yield* emailAccessGate({ + policyId: "SelfHostAllowUsers", + applicationId: "SelfHostAccess", + policyName: `open-seo ${stage} self-host users`, + applicationName: `open-seo ${stage}`, + domain: `${workerName(stage)}.${subdomain}`, + emails: allowedEmails, + }); + policyAud = application.aud; + } + + return { teamDomain, policyAud }; + }); + // Secrets/vars resolve from the env file passed to `alchemy deploy` // (`Config.redacted` → Cloudflare `secret_text`, `Config.string` → plaintext // var). NOTE: the alchemy CLI loads `--env-file` into the Config environment, @@ -166,6 +286,9 @@ const dataEnv = { ), TURNSTILE_SECRET_KEY: optionalSecret("TURNSTILE_SECRET_KEY"), TURNSTILE_SITE_KEY: optionalVar("TURNSTILE_SITE_KEY"), + // Alchemy reconciles worker vars on every deploy, so the telemetry opt-out + // must live in the env file — a dashboard-set var would be wiped. + OPENSEO_TELEMETRY_DISABLED: optionalVar("OPENSEO_TELEMETRY_DISABLED"), }; export default Alchemy.Stack( @@ -225,10 +348,11 @@ export default Alchemy.Stack( authUrl = ""; } - // cloudflare_access self-host reads these; hosted/local_noauth leave them - // empty. (Deriving/provisioning the Access application is a follow-up PR.) - const teamDomain = yield* optionalVar("TEAM_DOMAIN"); - const policyAud = yield* optionalVar("POLICY_AUD"); + const access = yield* resolveSelfHostAccess( + stage, + authMode === "cloudflare_access" && !prod, + workersSubdomain, + ); const app = yield* Cloudflare.Worker("open-seo", { name: workerName(stage), @@ -250,7 +374,12 @@ export default Alchemy.Stack( // Site audits parse and persist batches of HTML inside Workflow steps. // Paid Workers permit up to five minutes; keep headroom for unusually // link-heavy sites after bounding page bodies and bulk-writing links. - limits: { cpuMs: 300_000 }, + // Configurable CPU limits are a paid-plan feature, and self-host + // deploys (cloudflare_access) may run on the free plan — which rejects + // them — so those get the plan default instead. + ...(authMode === "cloudflare_access" + ? {} + : { limits: { cpuMs: 300_000 } }), observability: { enabled: wrangler.observability?.enabled ?? true, traces: { enabled: wrangler.observability?.traces?.enabled ?? false }, @@ -265,8 +394,8 @@ export default Alchemy.Stack( AUTH_MODE: authMode, DATABASE_PROVIDER: databaseProvider || "d1", BETTER_AUTH_URL: authUrl, - TEAM_DOMAIN: teamDomain, - POLICY_AUD: policyAud, + TEAM_DOMAIN: access.teamDomain, + POLICY_AUD: access.policyAud, // Prod-only: pooled Postgres via the existing Hyperdrive config. ...(prod ? { HYPERDRIVE: makeHyperdrive() } : {}), diff --git a/docs/DATAFORSEO_API_KEY.md b/docs/DATAFORSEO_API_KEY.md index 3cdfd88..9bc3411 100644 --- a/docs/DATAFORSEO_API_KEY.md +++ b/docs/DATAFORSEO_API_KEY.md @@ -15,5 +15,5 @@ New DataForSEO accounts include $1 of free credit to test with, and the minimum Set the value as `DATAFORSEO_API_KEY`: - **Docker self-hosting:** in `.env` (see [`SELF_HOSTING_DOCKER.md`](./SELF_HOSTING_DOCKER.md)). -- **Cloudflare self-hosting:** as a Worker secret in the dashboard under `Settings` -> `Variables & Secrets`, or with `pnpm exec wrangler secret put DATAFORSEO_API_KEY` (see [`SELF_HOSTING_CLOUDFLARE.md`](./SELF_HOSTING_CLOUDFLARE.md)). +- **Cloudflare self-hosting:** in `.env.selfhost` (see [`SELF_HOSTING_CLOUDFLARE.md`](./SELF_HOSTING_CLOUDFLARE.md)). Legacy button/Wrangler deployments: as a Worker secret in the dashboard under `Settings` -> `Variables & Secrets`. - **Local development:** in `.env.local` (see [`LOCAL_DEVELOPMENT.md`](./LOCAL_DEVELOPMENT.md)). diff --git a/docs/LOCAL_POSTGRES.md b/docs/LOCAL_POSTGRES.md index 0af736c..d1c008e 100644 --- a/docs/LOCAL_POSTGRES.md +++ b/docs/LOCAL_POSTGRES.md @@ -63,9 +63,7 @@ DATABASE_PROVIDER=postgres ``` The connection string comes from the `HYPERDRIVE` binding. The `hyperdrive` -block in `wrangler.jsonc` ships commented out (an active block makes the -"Deploy to Cloudflare" button demand a Postgres connection string), so -uncomment it first. Miniflare then resolves the binding to its +block in `wrangler.jsonc` ships commented out, so uncomment it first. Miniflare then resolves the binding to its `localConnectionString`, which already points at the Docker container from step 1. (In deployed Workers the same binding resolves to real Hyperdrive — the app never connects to Postgres except through this binding.) If your local diff --git a/docs/PREVIEW_DEPLOYMENTS.md b/docs/PREVIEW_DEPLOYMENTS.md index a91318e..ac160ce 100644 --- a/docs/PREVIEW_DEPLOYMENTS.md +++ b/docs/PREVIEW_DEPLOYMENTS.md @@ -197,7 +197,13 @@ The first Alchemy prod deploy adopts live resources. Before running it: ## Self-hosting on Cloudflare -Self-hosters deploy through wrangler (`wrangler.jsonc`) — see -docs/SELF_HOSTING_CLOUDFLARE.md. An alchemy-based self-host path (fresh -stage-suffixed resources plus a derived Cloudflare Access application) is a -planned fast-follow on top of this stack. +Self-hosters deploy the same stack under the fixed `selfhost` stage (via +`pnpm deploy:selfhost` — no stage to pass) with their own env file: Alchemy +provisions fresh D1/KV/R2/workflows by name (D1 is the database — no +Postgres/Hyperdrive), plus the Cloudflare Access application +gating the worker (`AUTH_MODE=cloudflare_access` + +`ACCESS_ALLOWED_EMAILS`; `resolveSelfHostAccess` in alchemy.run.ts derives +`TEAM_DOMAIN`/`POLICY_AUD`, or accepts them explicitly for a hand-managed +application). The preview Access wildcard and PR workflow are +OpenSEO-specific and not required. The walkthrough lives in +docs/SELF_HOSTING_CLOUDFLARE.md. diff --git a/docs/SELF_HOSTING_CLOUDFLARE.md b/docs/SELF_HOSTING_CLOUDFLARE.md index af7772f..14f5c62 100644 --- a/docs/SELF_HOSTING_CLOUDFLARE.md +++ b/docs/SELF_HOSTING_CLOUDFLARE.md @@ -1,77 +1,99 @@ # Cloudflare Self-Hosting -Host OpenSEO on Cloudflare for internet-facing self-hosting across multiple devices or with your team. It works on Cloudflare's free plan. +Host OpenSEO on Cloudflare for internet-facing self-hosting across multiple devices or with your team. One deploy command provisions everything, including the Cloudflare Access login gate. Works on Cloudflare's free plan. -This doc covers initial setup with the Deploy to Cloudflare button. Related guides: +Related guides: -- [Manual deploy with Wrangler](./SELF_HOSTING_CLOUDFLARE_MANUAL.md): use this if the deploy button fails or you want full control over resources. -- [Operations](./SELF_HOSTING_CLOUDFLARE_OPERATIONS.md): connect the MCP server, update to the latest version, add teammates, telemetry. +- [Operations](./SELF_HOSTING_CLOUDFLARE_OPERATIONS.md): connect the MCP server, telemetry. +- [Legacy deployments](./SELF_HOSTING_CLOUDFLARE_LEGACY.md): maintenance for installs created with the retired Deploy-button or manual Wrangler flows. -## 1) Deploy from GitHub +## Prerequisites -[![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/every-app/open-seo) +- **Node 22.6 or newer** and **pnpm** (`corepack enable` sets it up). +- **A Cloudflare account with R2 enabled.** Activating R2 requires a payment method on file, even within its free tier — if you have never used R2, open `R2` in the Cloudflare dashboard once. +- **A DataForSEO account** — see [`DATAFORSEO_API_KEY.md`](./DATAFORSEO_API_KEY.md). -Click the deploy button, there are lots of fields on the deploy form, but you only need to do the below steps. +## 1) Clone your OpenSEO repo -1. Connect your Git provider (GitHub/GitLab). -2. Leave the resource naming fields as default unless you have a reason to change them. -3. Click `Create and Deploy`. -4. Wait 1-2 minutes for deployment to finish. - -If deploy fails with `Cannot provision a KV Namespace with the title "open-seo" because it already exists`, use the [manual deploy with Wrangler](./SELF_HOSTING_CLOUDFLARE_MANUAL.md) flow instead. - -## 2) Configure authentication and secrets - -### Create the Access application - -1. In the main Cloudflare dashboard, go to `Compute` -> `Workers & Pages` -> your OpenSEO Worker -> `Settings` -> `Domains & Routes`. Copy the `workers.dev` hostname. It looks like `open-seo..workers.dev`. -2. Open [Cloudflare Zero Trust](https://one.dash.cloudflare.com/). -3. Go to `Access controls` -> `Applications` -> `Create new application` -> `Self-hosted and private`. -4. Name the application `OpenSEO`. -5. Under `Destinations` -> `Public hostnames`, click `Switch to custom input` and paste the exact `workers.dev` hostname from step 1. Enter only the hostname, without `https://` or a path. -6. Under `Access policies`, click `Create new policy` and configure: - - `Policy name`: `Allow OpenSEO users` - - `Action`: `Allow` - - `Include` selector: `Emails` - - Value: your Cloudflare account email -7. Do not choose `Everyone`; it allows anyone to reach the application. -8. Leave the other policy settings at their defaults, save the policy, then save the application. - -### Collect the values - -- `POLICY_AUD`: in `Access controls` -> `Applications`, select `Configure` on your application, then copy the `Application Audience (AUD) Tag` from `Additional settings`. -- `TEAM_DOMAIN`: `https://.cloudflareaccess.com`. Your team name is shown in Zero Trust `Settings`. Include the `https://` prefix. -- `DATAFORSEO_API_KEY`: follow [`DATAFORSEO_API_KEY.md`](./DATAFORSEO_API_KEY.md). - -### Set them on the Worker - -1. Go to `Compute` -> `Workers & Pages` -> your OpenSEO Worker -> `Settings` -> `Variables & Secrets`. -2. Add `TEAM_DOMAIN`, `POLICY_AUD`, and `DATAFORSEO_API_KEY`. - -## 3) Optional: add an R2 lifecycle rule - -DataForSEO API responses are cached in R2 under the `dataforseo-cache/` prefix. This step is optional, but recommended to automatically clean up expired cache objects: +Fork `every-app/open-seo` on GitHub if you want a repo you control, then clone it locally: ```bash -npx wrangler r2 bucket lifecycle add open-seo dataforseo-cache-expiry dataforseo-cache/ --expire-days 7 +git clone https://github.com/YOUR_GITHUB_USER/open-seo.git +cd open-seo +corepack enable +pnpm install ``` -If you changed the R2 bucket name during deploy, replace `open-seo` with your bucket name. +If you do not need a fork, clone the upstream repo instead: -Without a lifecycle rule, cached objects under `dataforseo-cache/` will accumulate indefinitely and increase storage costs over time. +```bash +git clone https://github.com/every-app/open-seo.git +cd open-seo +corepack enable +pnpm install +``` -## 4) Validate setup +## 2) Log in to Cloudflare (once) -1. Open your Worker URL again. +```bash +pnpm alchemy login # answer yes to "Customize OAuth scopes?" and enable access:write +pnpm alchemy cloudflare bootstrap # deploys alchemy's state-store Worker to your account +``` + +Already logged in from before without the `access:write` scope? Run `pnpm alchemy login --configure` — a plain repeat login doesn't re-ask about scopes. + +## 3) Create `.env.selfhost` + +Copy the template and fill in the required values: + +```bash +cp .env.selfhost.example .env.selfhost +``` + +## 4) Deploy + +```bash +pnpm deploy:selfhost --yes +``` + +This provisions the D1 database, KV namespaces, and R2 bucket, applies the database migrations, deploys the Worker, and creates the Cloudflare Access application protecting it (allowing exactly `ACCESS_ALLOWED_EMAILS`). If the account has no Zero Trust team yet, one is created for you, named after your workers.dev subdomain. + +To manage the Access application yourself instead, set `TEAM_DOMAIN` (`https://your-team.cloudflareaccess.com`) and `POLICY_AUD` (the application's audience tag) in `.env.selfhost` — the deploy then provisions no Access resources. + +## 5) Validate setup + +1. Open the Worker URL printed at the end of the deploy. 2. Sign in with Cloudflare Access. 3. OpenSEO should load after login. If it doesn't, see Troubleshooting below. +## Updating to the latest OpenSEO version + +```bash +git pull # or: git fetch upstream && git merge upstream/main, if you forked +pnpm install +pnpm deploy:selfhost --yes +``` + +## Giving teammates access + +Add the teammate to `ACCESS_ALLOWED_EMAILS` in `.env.selfhost` and redeploy. Dashboard edits to that Access policy are overwritten on the next deploy. (If you manage the Access application yourself, edit its Allow policy in Zero Trust instead.) + ## Troubleshooting -`https:///api/health` reports runtime configuration checks and database status. For server errors, open the Worker `Logs` or run `pnpm exec wrangler tail`. +- Login fails: re-check `ACCESS_ALLOWED_EMAILS` in `.env.selfhost` and redeploy. +- `https:///api/health` reports runtime configuration checks and database status. +- For server errors, open the Worker `Logs` or run `pnpm exec wrangler tail`. + +## Tearing it down + +```bash +pnpm alchemy destroy --env-file .env.selfhost --stage selfhost +``` + +This deletes the Worker, the stage-suffixed D1/KV/R2 resources (including your data), and the Access application. ## Next steps -See [Operations](./SELF_HOSTING_CLOUDFLARE_OPERATIONS.md) for connecting MCP clients, updating to the latest OpenSEO version, and giving teammates access. +See [Operations](./SELF_HOSTING_CLOUDFLARE_OPERATIONS.md) for connecting MCP clients and telemetry. diff --git a/docs/SELF_HOSTING_CLOUDFLARE_LEGACY.md b/docs/SELF_HOSTING_CLOUDFLARE_LEGACY.md new file mode 100644 index 0000000..36b98b2 --- /dev/null +++ b/docs/SELF_HOSTING_CLOUDFLARE_LEGACY.md @@ -0,0 +1,71 @@ +# Cloudflare Self-Hosting: Legacy Deployments + +Maintenance for installs created with the retired **Deploy to Cloudflare button** or the **manual Wrangler flow**. These deployments keep working — nothing changes for you. New deployments should use the [current guide](./SELF_HOSTING_CLOUDFLARE.md). + +## Updating (Deploy-button repos) + +Your repo was created by the deploy button and `wrangler.jsonc` holds your resource IDs; keep them while pulling the newest code. + +One-time setup: + +```bash +git remote add upstream https://github.com/every-app/open-seo.git +``` + +Update steps: + +```bash +git fetch upstream +cp wrangler.jsonc wrangler.local.backup.jsonc +git checkout main +git reset --hard upstream/main +cp wrangler.local.backup.jsonc wrangler.jsonc +git add wrangler.jsonc +git commit -m "restore Cloudflare settings" || true +git push --force-with-lease origin main +``` + +## Updating (manual Wrangler deployments) + +```bash +git pull +pnpm install +pnpm run deploy +``` + +## Giving teammates access + +1. Open Cloudflare Zero Trust. +2. Go to Access -> Applications. +3. Open your OpenSEO application. +4. Edit the `Allow` policy. +5. Add teammate emails (or your company email domain / group). +6. Save. + +Screenshots: [edit the Access policy](https://github.com/user-attachments/assets/c7bbc7b4-a18e-4ae4-9fe5-3b33c72048a7), [add teammate emails](https://github.com/user-attachments/assets/fa4ecaf2-31f7-4a64-9001-210cf729747b). + +## Optional: R2 lifecycle rule + +DataForSEO API responses are cached in R2 under the `dataforseo-cache/` prefix. Recommended so expired cache objects don't accumulate: + +```bash +pnpm exec wrangler r2 bucket lifecycle add open-seo dataforseo-cache-expiry dataforseo-cache/ --expire-days 7 +``` + +Replace `open-seo` with your bucket name if you changed it. + +## Troubleshooting + +**Login fails or OpenSEO doesn't load.** Re-check, on your Worker under `Settings`: + +- `Domains & Routes`: `Cloudflare Access` is enabled for the `workers.dev` route. +- `Variables & Secrets`: `TEAM_DOMAIN` (for example `https://your-team.cloudflareaccess.com`), `POLICY_AUD` (the Access application audience tag), and `DATAFORSEO_API_KEY` are set. +- Manual Wrangler deployments: the binding IDs in `wrangler.jsonc` match your resources. + +`https:///api/health` reports runtime configuration checks and database status. For server errors, open the Worker `Logs` or run `pnpm exec wrangler tail`. + +**Migrating to the current flow** is not supported yet — the new deploy provisions fresh resources, so your data would not move. Keep using this page. + +## Everything else + +MCP setup and telemetry work the same as current deployments — see [Operations](./SELF_HOSTING_CLOUDFLARE_OPERATIONS.md). diff --git a/docs/SELF_HOSTING_CLOUDFLARE_MANUAL.md b/docs/SELF_HOSTING_CLOUDFLARE_MANUAL.md deleted file mode 100644 index f4412ed..0000000 --- a/docs/SELF_HOSTING_CLOUDFLARE_MANUAL.md +++ /dev/null @@ -1,116 +0,0 @@ -# Cloudflare Self-Hosting: Manual Deploy with Wrangler - -Use this flow if the [Deploy to Cloudflare button](./SELF_HOSTING_CLOUDFLARE.md) fails with `Cannot provision a KV Namespace with the title "open-seo" because it already exists`. The reliable path is to create Cloudflare resources yourself, put their IDs into `wrangler.jsonc`, then deploy with Wrangler. - -## 1) Clone your OpenSEO repo - -Fork `every-app/open-seo` on GitHub if you want a repo you control for future updates, then clone it locally: - -```bash -git clone https://github.com/YOUR_GITHUB_USER/open-seo.git -cd open-seo -corepack enable -pnpm install -``` - -If you do not need a fork, clone the upstream repo instead: - -```bash -git clone https://github.com/every-app/open-seo.git -cd open-seo -corepack enable -pnpm install -``` - -## 2) Log in to Cloudflare - -```bash -pnpm exec wrangler login -``` - -## 3) Create Cloudflare resources - -Use unique names so they do not collide with resources that already exist in your Cloudflare account. Replace `YOUR_SUFFIX` with something unique to you, for example your GitHub username or company name. - -```bash -pnpm exec wrangler kv namespace create open-seo-YOUR_SUFFIX -pnpm exec wrangler kv namespace create open-seo-oauth-YOUR_SUFFIX -pnpm exec wrangler d1 create open-seo-YOUR_SUFFIX -pnpm exec wrangler r2 bucket create open-seo-YOUR_SUFFIX -``` - -Save the IDs and names printed by Wrangler: - -- The first KV namespace ID is for the `KV` binding. -- The second KV namespace ID is for the `OAUTH_KV` binding. -- The D1 `database_id` is for the `DB` binding. -- The R2 bucket name is for the `R2` binding. - -## 4) Edit `wrangler.jsonc` - -Open `wrangler.jsonc` and replace only your Cloudflare resource values. Keep the binding names exactly as shown below, because the application code expects those names. - -```jsonc -"kv_namespaces": [ - { - "binding": "KV", - "id": "YOUR_KV_NAMESPACE_ID", - }, - { - "binding": "OAUTH_KV", - "id": "YOUR_OAUTH_KV_NAMESPACE_ID", - }, -], -"d1_databases": [ - { - "binding": "DB", - "database_name": "open-seo-YOUR_SUFFIX", - "database_id": "YOUR_D1_DATABASE_ID", - "migrations_dir": "drizzle", - }, -], -"r2_buckets": [ - { - "bucket_name": "open-seo-YOUR_SUFFIX", - "binding": "R2", - }, -], -``` - -Do not use `wrangler deploy --update-config` for this step. Edit `wrangler.jsonc` manually so `"migrations_dir": "drizzle"` stays in the D1 database config. - -## 5) Deploy - -```bash -pnpm run deploy -``` - -## 6) Configure authentication and secrets - -Follow [Configure authentication and secrets](./SELF_HOSTING_CLOUDFLARE.md#2-configure-authentication-and-secrets), then set the values with Wrangler: - -```bash -pnpm exec wrangler secret put TEAM_DOMAIN -pnpm exec wrangler secret put POLICY_AUD -pnpm exec wrangler secret put DATAFORSEO_API_KEY -``` - -## 7) Optional: add an R2 lifecycle rule - -DataForSEO API responses are cached in R2 under the `dataforseo-cache/` prefix. This step is optional, but recommended to automatically clean up expired cache objects: - -```bash -pnpm exec wrangler r2 bucket lifecycle add open-seo-YOUR_SUFFIX dataforseo-cache-expiry dataforseo-cache/ --expire-days 7 -``` - -## 8) Validate setup - -1. Open your Worker URL again. -2. Sign in with Cloudflare Access. -3. OpenSEO should load after login. - -If login fails, check `/api/health`, the Worker logs, and the binding values in `wrangler.jsonc`. - -## Next steps - -See [Operations](./SELF_HOSTING_CLOUDFLARE_OPERATIONS.md) for connecting MCP clients, updating to the latest OpenSEO version, and giving teammates access. diff --git a/docs/SELF_HOSTING_CLOUDFLARE_OPERATIONS.md b/docs/SELF_HOSTING_CLOUDFLARE_OPERATIONS.md index 14bad9f..8510b68 100644 --- a/docs/SELF_HOSTING_CLOUDFLARE_OPERATIONS.md +++ b/docs/SELF_HOSTING_CLOUDFLARE_OPERATIONS.md @@ -1,6 +1,6 @@ # Cloudflare Self-Hosting: Operations -Day-to-day tasks after [initial setup](./SELF_HOSTING_CLOUDFLARE.md): connect the MCP server, update to the latest OpenSEO version, give teammates access, and manage telemetry. +Day-to-day tasks after [initial setup](./SELF_HOSTING_CLOUDFLARE.md): connect the MCP server and manage telemetry. Updating and teammate access are covered in the [deploy guide](./SELF_HOSTING_CLOUDFLARE.md) (or the [legacy page](./SELF_HOSTING_CLOUDFLARE_LEGACY.md) for pre-alchemy deployments). ## Connect the MCP server through Cloudflare Access @@ -26,56 +26,8 @@ MCP clients should connect to: https://YOUR_WORKER_HOSTNAME/mcp ``` -## How to update to the latest OpenSEO version - -If your repo was created from the Cloudflare Deploy button, use this flow. - -### One-time setup - -Run this once in your local repo: - -```bash -git remote add upstream https://github.com/every-app/open-seo.git -git fetch upstream -``` - -### Update steps (use every time) - -```bash -git fetch upstream -cp wrangler.jsonc wrangler.local.backup.jsonc -git checkout main -git reset --hard upstream/main -cp wrangler.local.backup.jsonc wrangler.jsonc -git add wrangler.jsonc -git commit -m "restore Cloudflare settings" || true -git push --force-with-lease origin main -``` - -Why this is needed: - -- `wrangler.jsonc` has your Cloudflare resource IDs. -- The update step keeps your IDs while pulling the newest OpenSEO code. - -## Give teammates access to OpenSEO - -1. Open Cloudflare Zero Trust. -2. Go to Access -> Applications. -3. Open your OpenSEO application. -4. Edit the `Allow` policy. -5. Add teammate emails (or your company email domain / group). -6. Save. - -Screenshots from the setup flow: - -- [Edit the Access policy](https://github.com/user-attachments/assets/c7bbc7b4-a18e-4ae4-9fe5-3b33c72048a7) -- [Add teammate emails to the allow list](https://github.com/user-attachments/assets/fa4ecaf2-31f7-4a64-9001-210cf729747b) - -After saving, teammates can open your OpenSEO URL and sign in through Cloudflare -Access. OpenSEO will use a shared workspace for everyone allowed by the policy. - ## Telemetry OpenSEO collects anonymized telemetry for core usage events: heartbeats with aggregate counts (installs, users, projects, feature usage) tied to a random install ID, sent every 5 minutes during the first two hours after install, then at most once daily. No URLs, keywords, prompts, emails, or IP-derived location are collected, and idle installs send nothing. -To disable it, add `OPENSEO_TELEMETRY_DISABLED=1` (or `DO_NOT_TRACK=1`) as a Worker variable under **Settings → Variables & Secrets**, then redeploy or restart the Worker. +To disable it, set `OPENSEO_TELEMETRY_DISABLED=1` in `.env.selfhost` and redeploy. Docker and [legacy deployments](./SELF_HOSTING_CLOUDFLARE_LEGACY.md): set it (or `DO_NOT_TRACK=1`) as an environment variable / Worker variable instead. diff --git a/package.json b/package.json index 473b335..17d4061 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "lint:fix": "oxlint . --type-aware --fix", "preview": "npm run build && vite preview --port 3001", "deploy": "npm run db:migrate:prod && npm run build && wrangler deploy", + "deploy:selfhost": "node scripts/selfhost-deploy-preflight.mjs && npm run build && pnpm alchemy deploy --env-file .env.selfhost --stage selfhost", "deploy:postgres": "npm run db:migrate:pg && npm run build && pnpm alchemy deploy --env-file .env.production --stage hosted-prod --adopt", "deploy:preview": "vite build --mode preview && pnpm alchemy deploy --env-file .env.preview", "preview:access": "pnpm alchemy deploy alchemy.preview-access.run.ts --env-file .env.preview --stage preview-access --adopt", @@ -117,6 +118,7 @@ "devDependencies": { "@cloudflare/vite-plugin": "^1.42.3", "@cloudflare/workers-types": "^4.20260611.1", + "@distilled.cloud/cloudflare": "0.28.2", "@effect/platform-node": "4.0.0-beta.93", "@libsql/client": "^0.15.15", "@playwright/test": "^1.59.1", @@ -129,6 +131,7 @@ "@types/react-dom": "^19.0.3", "@vitejs/plugin-react": "^4.6.0", "alchemy": "2.0.0-beta.61", + "chalk": "^5.6.2", "drizzle-kit": "^0.31.10", "effect": "4.0.0-beta.93", "knip": "^5.88.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4f42fc4..94d6070 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -151,6 +151,9 @@ importers: '@cloudflare/workers-types': specifier: ^4.20260611.1 version: 4.20260611.1 + '@distilled.cloud/cloudflare': + specifier: 0.28.2 + version: 0.28.2(effect@4.0.0-beta.93) '@effect/platform-node': specifier: 4.0.0-beta.93 version: 4.0.0-beta.93(effect@4.0.0-beta.93)(ioredis@5.11.1) @@ -187,6 +190,9 @@ importers: alchemy: specifier: 2.0.0-beta.61 version: 2.0.0-beta.61(@effect/platform-node@4.0.0-beta.93(effect@4.0.0-beta.93)(ioredis@5.11.1))(@types/node@22.19.11)(@types/react@19.2.14)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@cloudflare/workers-types@4.20260611.1)(@libsql/client@0.15.15)(@opentelemetry/api@1.9.1)(kysely@0.29.2)(mysql2@3.22.6(@types/node@22.19.11))(pg@8.22.0)(postgres@3.4.9)(sql.js@1.14.1))(effect@4.0.0-beta.93)(vite@7.3.6(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0))(vitest@3.2.6(@types/debug@4.1.13)(@types/node@22.19.11)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0))(workerd@1.20260625.1)(ws@8.21.0) + chalk: + specifier: ^5.6.2 + version: 5.6.2 drizzle-kit: specifier: ^0.31.10 version: 0.31.10 diff --git a/scripts/selfhost-deploy-preflight.mjs b/scripts/selfhost-deploy-preflight.mjs new file mode 100644 index 0000000..419ade6 --- /dev/null +++ b/scripts/selfhost-deploy-preflight.mjs @@ -0,0 +1,96 @@ +// Fast checks before `pnpm deploy:selfhost` spends minutes on the build — a +// missing env value or Cloudflare login should fail in seconds instead. +// (Distinct from scripts/selfhost-preflight.ts, the Docker container-start +// preflight that validates the runtime environment.) +// Everything here is best-effort duplication of errors alchemy would raise +// later anyway; when in doubt (unreadable profile, API-token auth) it stays +// quiet and lets the deploy be the judge. +import { existsSync, readFileSync } from "node:fs"; +import { homedir } from "node:os"; +import path from "node:path"; +import chalk from "chalk"; + +const cmd = chalk.cyan; +const em = chalk.yellow; + +const fail = (...lines) => { + console.error(`\n${chalk.red("deploy:selfhost preflight failed:")}\n`); + for (const line of lines) console.error(` ${line}`); + console.error(""); + process.exit(1); +}; + +// The `alchemy` script needs --experimental-strip-types (Node 22.6+). +const [major, minor] = process.versions.node.split(".").map(Number); +if (major < 22 || (major === 22 && minor < 6)) { + fail( + `Node ${em(process.versions.node)} is too old — the deploy needs Node 22.6 or newer (24 LTS recommended).`, + ); +} + +const envFile = ".env.selfhost"; +if (!existsSync(envFile)) { + fail( + `${em(envFile)} not found — create it first:`, + "", + ` ${cmd("cp .env.selfhost.example .env.selfhost")}`, + "", + `then set ${em("DATAFORSEO_API_KEY")} and ${em("ACCESS_ALLOWED_EMAILS")}.`, + ); +} +const env = {}; +for (const line of readFileSync(envFile, "utf8").split("\n")) { + const match = /^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/.exec(line); + if (match) env[match[1]] = match[2].replace(/^(["'])(.*)\1$/, "$2"); +} +if (!env.DATAFORSEO_API_KEY) { + fail( + `${em("DATAFORSEO_API_KEY")} is not set in ${envFile} — see docs/DATAFORSEO_API_KEY.md for how to get one.`, + ); +} +// When both are set, the deploy provisions no Access resources (hand-managed +// application) and needs neither ACCESS_ALLOWED_EMAILS nor the access:write +// login scope. +const managedAccess = !(env.TEAM_DOMAIN && env.POLICY_AUD); +if (managedAccess && !env.ACCESS_ALLOWED_EMAILS) { + fail( + `${em("ACCESS_ALLOWED_EMAILS")} is not set in ${envFile} — list who may sign in through`, + "Cloudflare Access (comma-separated emails), or set TEAM_DOMAIN and POLICY_AUD", + "to manage the Access application yourself.", + ); +} + +// An explicit API token bypasses login profiles entirely. +if (!process.env.CLOUDFLARE_API_TOKEN) { + const profileName = process.env.ALCHEMY_PROFILE || "default"; + let cloudflare; + try { + cloudflare = JSON.parse( + readFileSync(path.join(homedir(), ".alchemy", "profiles.json"), "utf8"), + ).profiles?.[profileName]?.Cloudflare; + } catch { + cloudflare = undefined; + } + if (!cloudflare) { + fail( + `No Cloudflare login found (alchemy profile "${profileName}") — run ${cmd("pnpm alchemy login")}`, + `first (answer yes to "Customize OAuth scopes?" and enable ${em("access:write")}).`, + ); + } + if ( + managedAccess && + cloudflare.method === "oauth" && + Array.isArray(cloudflare.scopes) && + !cloudflare.scopes.includes("access:write") + ) { + fail( + `Your Cloudflare login is missing the ${em("access:write")} scope, which the deploy needs`, + "to provision the Cloudflare Access login gate. Log in again with the scope enabled:", + "", + ` ${cmd("pnpm alchemy login --configure")}`, + "", + `When asked "Customize OAuth scopes?", answer yes, then select ${em("access:write")}`, + "(space to toggle, enter to confirm — keep the preselected defaults).", + ); + } +} diff --git a/src/routes/_app/ai.tsx b/src/routes/_app/ai.tsx index 8d96eda..68e45ef 100644 --- a/src/routes/_app/ai.tsx +++ b/src/routes/_app/ai.tsx @@ -1,5 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; -import { ArrowUpRight } from "lucide-react"; +import { ArrowUpRight, ShieldAlert } from "lucide-react"; +import { getAuthMode } from "@/lib/auth-mode"; import { captureClientEvent } from "@/client/lib/posthog"; import { ClaudeIcon, CodexIcon } from "@/client/features/ai-mcp/AgentIcons"; import { AvailableTools } from "@/client/features/ai-mcp/AvailableTools"; @@ -54,6 +55,24 @@ function AiPage() { domain lookups, and backlink reviews from your editor or chat.

+ {getAuthMode(import.meta.env.AUTH_MODE) === "cloudflare_access" ? ( +
+ + + This instance is behind Cloudflare Access. MCP clients cannot + connect until Managed OAuth is enabled on your Access application.{" "} + + Setup guide + + +
+ ) : null} +
diff --git a/web/content/docs/self-hosting/cloudflare.md b/web/content/docs/self-hosting/cloudflare.md index c258d92..5ab624d 100644 --- a/web/content/docs/self-hosting/cloudflare.md +++ b/web/content/docs/self-hosting/cloudflare.md @@ -3,53 +3,66 @@ title: "Cloudflare Self-Hosting" description: "Deploy OpenSEO to your own Cloudflare account for internet-facing, multi-device, or team use." --- -Host OpenSEO on Cloudflare for internet-facing self-hosting across multiple devices or with your team. It works on Cloudflare's free plan. +Host OpenSEO on Cloudflare for internet-facing self-hosting across multiple devices or with your team. One deploy command provisions everything, including the Cloudflare Access login gate. Works on Cloudflare's free plan. -## 1) Deploy from GitHub +## Prerequisites -[![Deploy to Cloudflare](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/every-app/open-seo) +- **Node 22.6 or newer** and **pnpm** (`corepack enable` sets it up). +- **A Cloudflare account with R2 enabled.** Activating R2 requires a payment method on file, even within its free tier — if you have never used R2, open `R2` in the Cloudflare dashboard once. +- **A DataForSEO account** — see [DataForSEO API key setup](/docs/self-hosting#dataforseo-api-key-setup). -Click the deploy button. There are lots of fields on the deploy form, but you only need to do the below steps. +## 1) Clone your OpenSEO repo -1. Connect your Git provider (GitHub/GitLab). -2. Leave the resource naming fields as default unless you have a reason to change them. -3. Click `Create and Deploy`. -4. Wait 1-2 minutes for deployment to finish. - -If deploy fails with `Cannot provision a KV Namespace with the title "open-seo" because it already exists`, use the [manual deploy with Wrangler guide on GitHub](https://github.com/every-app/open-seo/blob/main/docs/SELF_HOSTING_CLOUDFLARE_MANUAL.md) instead. - -## 2) Configure authentication and secrets - -In the Cloudflare dashboard: - -1. Go to `Compute` -> `Workers & Pages` -> your OpenSEO Worker. -2. Open `Settings`. -3. In `Domains & Routes`, enable `Cloudflare Access` for the `workers.dev` route. -4. Save the values shown by Cloudflare Access. -5. In `Variables & Secrets`, add: - - `POLICY_AUD` (from Access setup) - - `TEAM_DOMAIN` (domain from `JWKS_URL`, for example `https://your-team.cloudflareaccess.com`) - - `DATAFORSEO_API_KEY` (see [DataForSEO API key setup](/docs/self-hosting#dataforseo-api-key-setup)) - -## 3) Optional: add an R2 lifecycle rule - -DataForSEO API responses are cached in R2 under the `dataforseo-cache/` prefix. This step is optional, but recommended to automatically clean up expired cache objects: +Fork `every-app/open-seo` on GitHub if you want a repo you control, then clone it locally: ```bash -npx wrangler r2 bucket lifecycle add open-seo dataforseo-cache-expiry dataforseo-cache/ --expire-days 7 +git clone https://github.com/YOUR_GITHUB_USER/open-seo.git +cd open-seo +corepack enable +pnpm install ``` -If you changed the R2 bucket name during deploy, replace `open-seo` with your bucket name. +If you do not need a fork, clone the upstream repo instead: -Without a lifecycle rule, cached objects under `dataforseo-cache/` will accumulate indefinitely and increase storage costs over time. +```bash +git clone https://github.com/every-app/open-seo.git +cd open-seo +corepack enable +pnpm install +``` -## 4) Validate setup +## 2) Log in to Cloudflare (once) -1. Open your Worker URL again. +```bash +pnpm alchemy login # answer yes to "Customize OAuth scopes?" and enable access:write +pnpm alchemy cloudflare bootstrap # deploys alchemy's state-store Worker to your account +``` + +Already logged in from before without the `access:write` scope? Run `pnpm alchemy login --configure` — a plain repeat login doesn't re-ask about scopes. + +## 3) Create `.env.selfhost` + +Copy the template and fill in the required values: + +```bash +cp .env.selfhost.example .env.selfhost +``` + +## 4) Deploy + +```bash +pnpm deploy:selfhost --yes +``` + +This provisions the D1 database, KV namespaces, and R2 bucket, applies the database migrations, deploys the Worker, and creates the Cloudflare Access application protecting it (allowing exactly `ACCESS_ALLOWED_EMAILS`). If the account has no Zero Trust team yet, one is created for you, named after your workers.dev subdomain. + +## 5) Validate setup + +1. Open the Worker URL printed at the end of the deploy. 2. Sign in with Cloudflare Access. 3. OpenSEO should load after login. -If login fails, re-check the three secrets and Access toggle. +If login fails, re-check `ACCESS_ALLOWED_EMAILS` and redeploy. ## Connect the MCP server through Cloudflare Access @@ -74,16 +87,17 @@ https://YOUR_WORKER_HOSTNAME/mcp ## Give teammates access to OpenSEO -1. Open Cloudflare Zero Trust. -2. Go to Access -> Applications. -3. Open your OpenSEO application. -4. Edit the `Allow` policy. -5. Add teammate emails (or your company email domain / group). -6. Save. +Add the teammate to `ACCESS_ALLOWED_EMAILS` in `.env.selfhost` and redeploy. Everyone allowed through shares one OpenSEO workspace. -After saving, teammates can open your OpenSEO URL and sign in through Cloudflare Access. OpenSEO will use a shared workspace for everyone allowed by the policy. +## Updating to the latest OpenSEO version -## Advanced guides on GitHub +```bash +git pull # or: git fetch upstream && git merge upstream/main, if you forked +pnpm install +pnpm deploy:selfhost --yes +``` -- [Manual deploy with Wrangler](https://github.com/every-app/open-seo/blob/main/docs/SELF_HOSTING_CLOUDFLARE_MANUAL.md): create the Cloudflare resources yourself and deploy with the CLI. -- [Operations](https://github.com/every-app/open-seo/blob/main/docs/SELF_HOSTING_CLOUDFLARE_OPERATIONS.md): update to the latest OpenSEO version and manage telemetry. +## More guides on GitHub + +- [Operations](https://github.com/every-app/open-seo/blob/main/docs/SELF_HOSTING_CLOUDFLARE_OPERATIONS.md): telemetry and other day-to-day tasks. +- [Legacy deployments](https://github.com/every-app/open-seo/blob/main/docs/SELF_HOSTING_CLOUDFLARE_LEGACY.md): maintenance for installs created with the retired Deploy-button or manual Wrangler flows. diff --git a/wrangler.jsonc b/wrangler.jsonc index b231a13..ddbb675 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -94,10 +94,9 @@ // docs/LOCAL_POSTGRES.md, and nothing connects to it unless // DATABASE_PROVIDER=postgres is set in .env.local. // - // Kept commented out: an active hyperdrive block makes the "Deploy to - // Cloudflare" button demand a Postgres connection string, and the id lives in - // OpenSEO's account anyway (Alchemy deploys and the Docker image never read - // it). Uncomment when running local Postgres dev per docs/LOCAL_POSTGRES.md. + // Kept commented out: the id lives in OpenSEO's account, and only local + // Postgres dev reads this block (Alchemy deploys and the Docker image never + // do). Uncomment when running local Postgres dev per docs/LOCAL_POSTGRES.md. // "hyperdrive": [ // { // "binding": "HYPERDRIVE",