feat(deploy): alchemy-based Cloudflare self-hosting (#431)
This commit is contained in:
parent
625b76ebee
commit
ffb5c9d9e8
32
.env.selfhost.example
Normal file
32
.env.selfhost.example
Normal 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
1
.gitignore
vendored
@ -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
|
||||||
|
|||||||
159
alchemy.run.ts
159
alchemy.run.ts
@ -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() } : {}),
|
||||||
|
|||||||
@ -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)).
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
[](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.
|
||||||
|
|||||||
71
docs/SELF_HOSTING_CLOUDFLARE_LEGACY.md
Normal file
71
docs/SELF_HOSTING_CLOUDFLARE_LEGACY.md
Normal 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).
|
||||||
@ -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.
|
|
||||||
@ -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.
|
||||||
|
|||||||
@ -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
6
pnpm-lock.yaml
generated
@ -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
|
||||||
|
|||||||
96
scripts/selfhost-deploy-preflight.mjs
Normal file
96
scripts/selfhost-deploy-preflight.mjs
Normal 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).",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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">
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
[](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.
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user