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
-[](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
-[](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",