feat(deploy): alchemy-based Cloudflare self-hosting (#431)

This commit is contained in:
Ben Senescu 2026-07-29 23:22:33 -04:00 committed by GitHub
parent 625b76ebee
commit ffb5c9d9e8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 521 additions and 289 deletions

32
.env.selfhost.example Normal file
View File

@ -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

1
.gitignore vendored
View File

@ -18,6 +18,7 @@ dist-sourcemaps/
!.env.example !.env.example
!.env.preview.example !.env.preview.example
!.env.production.example !.env.production.example
!.env.selfhost.example
.vercel .vercel
.output .output
.nitro .nitro

View File

@ -1,13 +1,18 @@
import * as Alchemy from "alchemy"; import * as Alchemy from "alchemy";
import * as Cloudflare from "alchemy/Cloudflare"; 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 Config from "effect/Config";
import * as Console from "effect/Console";
import * as Effect from "effect/Effect"; import * as Effect from "effect/Effect";
import { Redacted } from "effect"; import { Redacted } from "effect";
import { unstable_readConfig } from "wrangler"; import { unstable_readConfig } from "wrangler";
import { z } from "zod"; import { z } from "zod";
import { import {
emailAccessGate,
HOSTED_PROD_STAGE, HOSTED_PROD_STAGE,
readWorkersSubdomain, readWorkersSubdomain,
requireAllowedEmails,
workerName, workerName,
} from "./alchemy.access.ts"; } from "./alchemy.access.ts";
@ -16,20 +21,20 @@ import {
// boundary depends on. The shell copy in .github/workflows/pr-preview.yml // boundary depends on. The shell copy in .github/workflows/pr-preview.yml
// must be kept in sync by hand. // must be kept in sync by hand.
// Alchemy v2 stack for SaaS deployments — previews and prod. Stage semantics, // Alchemy v2 stack for SaaS deployments — previews, prod, and Cloudflare
// security model, and credentials are documented once in // self-hosting. Stage semantics, security model, and credentials are
// docs/PREVIEW_DEPLOYMENTS.md. // documented once in docs/PREVIEW_DEPLOYMENTS.md.
// //
// - Any stage except "hosted-prod": fresh stage-suffixed resources. Previews // - Any stage except "hosted-prod": fresh stage-suffixed resources. Previews
// deploy via `pnpm deploy:preview --stage <name>`. // deploy via `pnpm deploy:preview --stage <name>`; self-hosters via
// `pnpm deploy:selfhost` (stage "selfhost", no flag to pass).
// - Stage "hosted-prod": names the EXISTING openseo.so production resources // - Stage "hosted-prod": names the EXISTING openseo.so production resources
// so `--adopt` imports them. Deploy via `pnpm deploy:postgres` (--adopt and // so `--adopt` imports them. Deploy via `pnpm deploy:postgres` (--adopt and
// the stage baked in). // the stage baked in).
// //
// Self-hosting still deploys through wrangler (wrangler.jsonc); an // Local dev and Docker self-host do NOT use this stack (wrangler.jsonc +
// alchemy-based self-host path is a planned fast-follow. Local dev and Docker // @cloudflare/vite-plugin). This stack deploys the PREBUILT `vite build`
// self-host do NOT use this stack (wrangler.jsonc + @cloudflare/vite-plugin). // output — Alchemy never runs Vite.
// This stack deploys the PREBUILT `vite build` output — Alchemy never runs Vite.
// The worker's runtime contract — compatibility date/flags, crons, // The worker's runtime contract — compatibility date/flags, crons,
// observability, placement, DO/workflow classes — has one source of truth: // observability, placement, DO/workflow classes — has one source of truth:
@ -87,6 +92,21 @@ const makeResources = (stage: string) => {
}).pipe(keep), }).pipe(keep),
R2: Cloudflare.R2.Bucket("R2", { R2: Cloudflare.R2.Bucket("R2", {
name: prod ? PROD_NAMES.r2 : `open-seo-r2-${stage}`, 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), }).pipe(keep),
KV: Cloudflare.KV.Namespace("KV", { KV: Cloudflare.KV.Namespace("KV", {
title: prod ? PROD_NAMES.kv : `open-seo-kv-${stage}`, title: prod ? PROD_NAMES.kv : `open-seo-kv-${stage}`,
@ -135,6 +155,106 @@ const optionalVar = (name: string) =>
const optionalSecret = (name: string) => const optionalSecret = (name: string) =>
Config.redacted(name).pipe(Config.withDefault(Redacted.make(""))); 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<string> = 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` // Secrets/vars resolve from the env file passed to `alchemy deploy`
// (`Config.redacted` → Cloudflare `secret_text`, `Config.string` → plaintext // (`Config.redacted` → Cloudflare `secret_text`, `Config.string` → plaintext
// var). NOTE: the alchemy CLI loads `--env-file` into the Config environment, // 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_SECRET_KEY: optionalSecret("TURNSTILE_SECRET_KEY"),
TURNSTILE_SITE_KEY: optionalVar("TURNSTILE_SITE_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( export default Alchemy.Stack(
@ -225,10 +348,11 @@ export default Alchemy.Stack(
authUrl = ""; authUrl = "";
} }
// cloudflare_access self-host reads these; hosted/local_noauth leave them const access = yield* resolveSelfHostAccess(
// empty. (Deriving/provisioning the Access application is a follow-up PR.) stage,
const teamDomain = yield* optionalVar("TEAM_DOMAIN"); authMode === "cloudflare_access" && !prod,
const policyAud = yield* optionalVar("POLICY_AUD"); workersSubdomain,
);
const app = yield* Cloudflare.Worker("open-seo", { const app = yield* Cloudflare.Worker("open-seo", {
name: workerName(stage), name: workerName(stage),
@ -250,7 +374,12 @@ export default Alchemy.Stack(
// Site audits parse and persist batches of HTML inside Workflow steps. // Site audits parse and persist batches of HTML inside Workflow steps.
// Paid Workers permit up to five minutes; keep headroom for unusually // Paid Workers permit up to five minutes; keep headroom for unusually
// link-heavy sites after bounding page bodies and bulk-writing links. // 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: { observability: {
enabled: wrangler.observability?.enabled ?? true, enabled: wrangler.observability?.enabled ?? true,
traces: { enabled: wrangler.observability?.traces?.enabled ?? false }, traces: { enabled: wrangler.observability?.traces?.enabled ?? false },
@ -265,8 +394,8 @@ export default Alchemy.Stack(
AUTH_MODE: authMode, AUTH_MODE: authMode,
DATABASE_PROVIDER: databaseProvider || "d1", DATABASE_PROVIDER: databaseProvider || "d1",
BETTER_AUTH_URL: authUrl, BETTER_AUTH_URL: authUrl,
TEAM_DOMAIN: teamDomain, TEAM_DOMAIN: access.teamDomain,
POLICY_AUD: policyAud, POLICY_AUD: access.policyAud,
// Prod-only: pooled Postgres via the existing Hyperdrive config. // Prod-only: pooled Postgres via the existing Hyperdrive config.
...(prod ? { HYPERDRIVE: makeHyperdrive() } : {}), ...(prod ? { HYPERDRIVE: makeHyperdrive() } : {}),

View File

@ -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`: Set the value as `DATAFORSEO_API_KEY`:
- **Docker self-hosting:** in `.env` (see [`SELF_HOSTING_DOCKER.md`](./SELF_HOSTING_DOCKER.md)). - **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)). - **Local development:** in `.env.local` (see [`LOCAL_DEVELOPMENT.md`](./LOCAL_DEVELOPMENT.md)).

View File

@ -63,9 +63,7 @@ DATABASE_PROVIDER=postgres
``` ```
The connection string comes from the `HYPERDRIVE` binding. The `hyperdrive` The connection string comes from the `HYPERDRIVE` binding. The `hyperdrive`
block in `wrangler.jsonc` ships commented out (an active block makes the block in `wrangler.jsonc` ships commented out, so uncomment it first. Miniflare then resolves the binding to its
"Deploy to Cloudflare" button demand a Postgres connection string), so
uncomment it first. Miniflare then resolves the binding to its
`localConnectionString`, which already points at the Docker container from `localConnectionString`, which already points at the Docker container from
step 1. (In deployed Workers the same binding resolves to real Hyperdrive — 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 the app never connects to Postgres except through this binding.) If your local

View File

@ -197,7 +197,13 @@ The first Alchemy prod deploy adopts live resources. Before running it:
## Self-hosting on Cloudflare ## Self-hosting on Cloudflare
Self-hosters deploy through wrangler (`wrangler.jsonc`) — see Self-hosters deploy the same stack under the fixed `selfhost` stage (via
docs/SELF_HOSTING_CLOUDFLARE.md. An alchemy-based self-host path (fresh `pnpm deploy:selfhost` — no stage to pass) with their own env file: Alchemy
stage-suffixed resources plus a derived Cloudflare Access application) is a provisions fresh D1/KV/R2/workflows by name (D1 is the database — no
planned fast-follow on top of this stack. 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.

View File

@ -1,77 +1,99 @@
# Cloudflare Self-Hosting # 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, telemetry.
- [Operations](./SELF_HOSTING_CLOUDFLARE_OPERATIONS.md): connect the MCP server, update to the latest version, add teammates, 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). Fork `every-app/open-seo` on GitHub if you want a repo you control, then clone it locally:
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.<your-subdomain>.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://<team-name>.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:
```bash ```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. 2. Sign in with Cloudflare Access.
3. OpenSEO should load after login. 3. OpenSEO should load after login.
If it doesn't, see Troubleshooting below. 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 ## Troubleshooting
`https://<your-worker-hostname>/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://<your-worker-hostname>/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 ## 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.

View File

@ -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://<your-worker-hostname>/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).

View File

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

View File

@ -1,6 +1,6 @@
# Cloudflare Self-Hosting: Operations # 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 ## Connect the MCP server through Cloudflare Access
@ -26,56 +26,8 @@ MCP clients should connect to:
https://YOUR_WORKER_HOSTNAME/mcp 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 ## 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. 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.

View File

@ -15,6 +15,7 @@
"lint:fix": "oxlint . --type-aware --fix", "lint:fix": "oxlint . --type-aware --fix",
"preview": "npm run build && vite preview --port 3001", "preview": "npm run build && vite preview --port 3001",
"deploy": "npm run db:migrate:prod && npm run build && wrangler deploy", "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: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", "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", "preview:access": "pnpm alchemy deploy alchemy.preview-access.run.ts --env-file .env.preview --stage preview-access --adopt",
@ -117,6 +118,7 @@
"devDependencies": { "devDependencies": {
"@cloudflare/vite-plugin": "^1.42.3", "@cloudflare/vite-plugin": "^1.42.3",
"@cloudflare/workers-types": "^4.20260611.1", "@cloudflare/workers-types": "^4.20260611.1",
"@distilled.cloud/cloudflare": "0.28.2",
"@effect/platform-node": "4.0.0-beta.93", "@effect/platform-node": "4.0.0-beta.93",
"@libsql/client": "^0.15.15", "@libsql/client": "^0.15.15",
"@playwright/test": "^1.59.1", "@playwright/test": "^1.59.1",
@ -129,6 +131,7 @@
"@types/react-dom": "^19.0.3", "@types/react-dom": "^19.0.3",
"@vitejs/plugin-react": "^4.6.0", "@vitejs/plugin-react": "^4.6.0",
"alchemy": "2.0.0-beta.61", "alchemy": "2.0.0-beta.61",
"chalk": "^5.6.2",
"drizzle-kit": "^0.31.10", "drizzle-kit": "^0.31.10",
"effect": "4.0.0-beta.93", "effect": "4.0.0-beta.93",
"knip": "^5.88.1", "knip": "^5.88.1",

6
pnpm-lock.yaml generated
View File

@ -151,6 +151,9 @@ importers:
'@cloudflare/workers-types': '@cloudflare/workers-types':
specifier: ^4.20260611.1 specifier: ^4.20260611.1
version: 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': '@effect/platform-node':
specifier: 4.0.0-beta.93 specifier: 4.0.0-beta.93
version: 4.0.0-beta.93(effect@4.0.0-beta.93)(ioredis@5.11.1) version: 4.0.0-beta.93(effect@4.0.0-beta.93)(ioredis@5.11.1)
@ -187,6 +190,9 @@ importers:
alchemy: alchemy:
specifier: 2.0.0-beta.61 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) 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: drizzle-kit:
specifier: ^0.31.10 specifier: ^0.31.10
version: 0.31.10 version: 0.31.10

View File

@ -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).",
);
}
}

View File

@ -1,5 +1,6 @@
import { createFileRoute } from "@tanstack/react-router"; 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 { captureClientEvent } from "@/client/lib/posthog";
import { ClaudeIcon, CodexIcon } from "@/client/features/ai-mcp/AgentIcons"; import { ClaudeIcon, CodexIcon } from "@/client/features/ai-mcp/AgentIcons";
import { AvailableTools } from "@/client/features/ai-mcp/AvailableTools"; import { AvailableTools } from "@/client/features/ai-mcp/AvailableTools";
@ -54,6 +55,24 @@ function AiPage() {
domain lookups, and backlink reviews from your editor or chat. domain lookups, and backlink reviews from your editor or chat.
</p> </p>
{getAuthMode(import.meta.env.AUTH_MODE) === "cloudflare_access" ? (
<div className="alert alert-warning mt-6 text-sm" role="alert">
<ShieldAlert className="size-4 shrink-0" />
<span>
This instance is behind Cloudflare Access. MCP clients cannot
connect until Managed OAuth is enabled on your Access application.{" "}
<a
href="https://openseo.so/docs/self-hosting/cloudflare#connect-the-mcp-server-through-cloudflare-access"
target="_blank"
rel="noreferrer"
className="link font-medium"
>
Setup guide
</a>
</span>
</div>
) : null}
<section className="mt-8"> <section className="mt-8">
<div className="rounded-lg border border-base-300 bg-base-200 px-4 py-3.5"> <div className="rounded-lg border border-base-300 bg-base-200 px-4 py-3.5">
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">

View File

@ -3,53 +3,66 @@ title: "Cloudflare Self-Hosting"
description: "Deploy OpenSEO to your own Cloudflare account for internet-facing, multi-device, or team use." 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). Fork `every-app/open-seo` on GitHub if you want a repo you control, then clone it locally:
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:
```bash ```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. 2. Sign in with Cloudflare Access.
3. OpenSEO should load after login. 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 ## Connect the MCP server through Cloudflare Access
@ -74,16 +87,17 @@ https://YOUR_WORKER_HOSTNAME/mcp
## Give teammates access to OpenSEO ## Give teammates access to OpenSEO
1. Open Cloudflare Zero Trust. Add the teammate to `ACCESS_ALLOWED_EMAILS` in `.env.selfhost` and redeploy. Everyone allowed through shares one OpenSEO workspace.
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.
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. ## More guides on GitHub
- [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.
- [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.

View File

@ -94,10 +94,9 @@
// docs/LOCAL_POSTGRES.md, and nothing connects to it unless // docs/LOCAL_POSTGRES.md, and nothing connects to it unless
// DATABASE_PROVIDER=postgres is set in .env.local. // DATABASE_PROVIDER=postgres is set in .env.local.
// //
// Kept commented out: an active hyperdrive block makes the "Deploy to // Kept commented out: the id lives in OpenSEO's account, and only local
// Cloudflare" button demand a Postgres connection string, and the id lives in // Postgres dev reads this block (Alchemy deploys and the Docker image never
// OpenSEO's account anyway (Alchemy deploys and the Docker image never read // do). Uncomment when running local Postgres dev per docs/LOCAL_POSTGRES.md.
// it). Uncomment when running local Postgres dev per docs/LOCAL_POSTGRES.md.
// "hyperdrive": [ // "hyperdrive": [
// { // {
// "binding": "HYPERDRIVE", // "binding": "HYPERDRIVE",