Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,12 +116,18 @@ Prerequisites:

- Install Docker: https://www.docker.com/products/docker-desktop/

Quickstart:
Secure quickstart:

1. `cp .env.example .env`
2. Set `DATAFORSEO_API_KEY` in `.env`
3. `docker compose up -d`
4. Open `http://localhost:<PORT>` (default `3001`)
3. Keep the default `AUTH_MODE=cloudflare_access` and set `TEAM_DOMAIN` + `POLICY_AUD` from your Cloudflare Access application
4. Set `ALLOWED_HOST` to your protected hostname when deploying through Coolify or a reverse proxy
5. `docker compose up -d`
6. Open the Cloudflare Access-protected hostname, or `http://localhost:<PORT>` (default `3001`) for local testing

For trusted local-only development without Cloudflare Access, explicitly set
`AUTH_MODE=local_noauth`. Do not expose `local_noauth` deployments directly to
the internet.

By default, `compose.yaml` pulls the published image from GHCR:

Expand Down
4 changes: 3 additions & 1 deletion compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ services:
- CLOUDFLARE_INCLUDE_PROCESS_ENV=true
- PORT=${PORT:-3001}
- ALLOWED_HOST=${ALLOWED_HOST:-}
- AUTH_MODE=local_noauth
- AUTH_MODE=${AUTH_MODE:-cloudflare_access}
- TEAM_DOMAIN=${TEAM_DOMAIN:-}
- POLICY_AUD=${POLICY_AUD:-}
- DATAFORSEO_API_KEY=${DATAFORSEO_API_KEY}
- VITE_SHOW_DEVTOOLS=false
ports:
Expand Down
89 changes: 73 additions & 16 deletions docs/SELF_HOSTING_DOCKER.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
# Docker Self-Hosting

Run OpenSEO locally with Docker.
Run OpenSEO locally or in Coolify with Docker.

In Docker mode, OpenSEO uses `AUTH_MODE=local_noauth` (no auth checks, local admin user `admin@localhost`). Only expose it behind your own auth-protected reverse proxy, tunnel, or private network.
Docker self-hosting is secure-by-default: `compose.yaml` defaults to
`AUTH_MODE=cloudflare_access` and passes Cloudflare Access settings into the
container so the app and MCP endpoint validate the `cf-access-jwt-assertion`
header before creating an OpenSEO user context.

Use `AUTH_MODE=local_noauth` only for trusted local development on a private
machine or private network. Do not expose `local_noauth` deployments directly to
the internet.

The default `compose.yaml` uses the published GHCR image:

Expand All @@ -11,32 +18,65 @@ The default `compose.yaml` uses the published GHCR image:
## Prerequisites

- Docker Desktop (or Docker Engine + Docker Compose)
- A DataForSEO API credential encoded as base64 `email:password`
- For production/Coolify: a Cloudflare Access application in front of the OpenSEO route

## Secure quickstart: Docker or Coolify behind Cloudflare Access

## Quickstart
1. Copy the example environment file:

```bash
cp .env.example .env
```

2. Set the required values in `.env`:

```bash
DATAFORSEO_API_KEY=base64-email-colon-password
AUTH_MODE=cloudflare_access
TEAM_DOMAIN=https://your-team.cloudflareaccess.com
POLICY_AUD=your-cloudflare-access-aud-tag
ALLOWED_HOST=seo.example.com
```

3. Start the container:

```bash
docker compose up -d
```

Set `DATAFORSEO_API_KEY` in `.env`, then open `http://localhost:<PORT>` (default `3001`).
Docker Compose passes `.env` values into the container. `compose.yaml` also sets
`CLOUDFLARE_INCLUDE_PROCESS_ENV=true` so the Cloudflare Vite runtime can read
those values as Worker bindings during Docker self-hosting.

If `AUTH_MODE=cloudflare_access` is selected but `TEAM_DOMAIN` or `POLICY_AUD`
is missing, OpenSEO returns a clear configuration error instead of silently
falling back to unauthenticated mode.

## Coolify notes

Docker Compose passes `.env` values into the container, and `compose.yaml` enables `CLOUDFLARE_INCLUDE_PROCESS_ENV=true` so the Cloudflare Vite runtime can read them as Worker bindings during local self-hosting.
For Coolify, configure these as service environment variables:

Optional env values:
- `DATAFORSEO_API_KEY`: base64 `email:password`
- `AUTH_MODE=cloudflare_access`
- `TEAM_DOMAIN=https://your-team.cloudflareaccess.com`
- `POLICY_AUD`: Cloudflare Access application AUD tag
- `ALLOWED_HOST`: the public hostname Coolify/Cloudflare routes to OpenSEO
- `PORT=3001` unless you intentionally change the exposed app port

- `PORT` (defaults to `3001`)
- `ALLOWED_HOST` (single reverse-proxy hostname to allow in Vite preview)
- `AUTH_MODE=local_noauth` (already set in compose)
- `OPEN_SEO_IMAGE` (defaults to `ghcr.io/every-app/open-seo:latest`)
Keep Cloudflare Access enabled on the public route. OpenSEO validates Access JWTs
for both the browser app and the self-hosted MCP transport.

If you are putting Docker behind a reverse proxy or a temporary tunnel, remember that Docker self-hosting runs with app auth disabled. Only expose it behind your own auth-protected reverse proxy, tunnel, or private network, and add the public hostname before restarting:
## Trusted local development mode

For local-only testing without Cloudflare Access, explicitly opt in:

```bash
ALLOWED_HOST=yourdomain.com docker compose up -d
AUTH_MODE=local_noauth docker compose up -d
```

You can also persist it in `.env`.
`local_noauth` injects the local admin user `admin@localhost`. It is intended for
trusted local development only.

## Pin to a specific image tag

Expand Down Expand Up @@ -90,12 +130,29 @@ To confirm Docker Compose is using the expected environment variables:
docker compose config
```

Check that `AUTH_MODE=local_noauth`, and that `DATAFORSEO_API_KEY` is the base64
encoded value of your DataForSEO email and API password in this format:
`email:password`.
Check that the rendered config includes:

- `AUTH_MODE=cloudflare_access` for secure deployments, or an explicit
`AUTH_MODE=local_noauth` only for trusted local development
- `TEAM_DOMAIN=https://your-team.cloudflareaccess.com`
- `POLICY_AUD=your-cloudflare-access-aud-tag`
- `CLOUDFLARE_INCLUDE_PROCESS_ENV=true`
- `DATAFORSEO_API_KEY` as the base64 encoded value of your DataForSEO email and
API password in this format: `email:password`

If you changed `.env`, recreate the container so Compose reapplies it:

```bash
docker compose up -d --force-recreate open-seo
```

## Manual verification path

1. Start with `AUTH_MODE=cloudflare_access`, `TEAM_DOMAIN`, and `POLICY_AUD` set.
2. Access the app through the Cloudflare-protected hostname and confirm the UI
loads after Access authentication.
3. Call the MCP route through the same protected hostname and confirm requests
without a valid `cf-access-jwt-assertion` are rejected.
4. Temporarily remove `POLICY_AUD` and restart; the app should return a clear
`AUTH_MODE=cloudflare_access requires TEAM_DOMAIN and POLICY_AUD` error.
5. Restore `POLICY_AUD` and recreate the container.
69 changes: 69 additions & 0 deletions src/lib/self-host-auth-config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import {
assertSelfHostedAuthConfig,
getSelfHostedAuthConfigMessage,
} from "./self-host-auth-config";

describe("self-hosted auth configuration", () => {
it("accepts Cloudflare Access when TEAM_DOMAIN and POLICY_AUD are present", () => {
expect(() =>
assertSelfHostedAuthConfig({
AUTH_MODE: "cloudflare_access",
TEAM_DOMAIN: "https://team.cloudflareaccess.com",
POLICY_AUD: "access-aud-tag",
}),
).not.toThrow();
});

it("rejects Cloudflare Access with a clear missing-variable message", () => {
expect(() =>
assertSelfHostedAuthConfig({
AUTH_MODE: "cloudflare_access",
TEAM_DOMAIN: "",
POLICY_AUD: undefined,
}),
).toThrow(
"AUTH_MODE=cloudflare_access requires TEAM_DOMAIN and POLICY_AUD",
);
});

it("keeps local_noauth explicitly available for trusted local development", () => {
expect(() =>
assertSelfHostedAuthConfig({ AUTH_MODE: "local_noauth" }),
).not.toThrow();
});

it("returns null when self-hosted auth config is complete", () => {
expect(
getSelfHostedAuthConfigMessage({
AUTH_MODE: "cloudflare_access",
TEAM_DOMAIN: "https://team.cloudflareaccess.com",
POLICY_AUD: "access-aud-tag",
}),
).toBeNull();
});
});

describe("Docker self-hosting files", () => {
it("defaults compose deployments to Cloudflare Access and passes required env through", () => {
const compose = readFileSync(resolve("compose.yaml"), "utf8");

expect(compose).toContain("AUTH_MODE=${AUTH_MODE:-cloudflare_access}");
expect(compose).toContain("TEAM_DOMAIN=${TEAM_DOMAIN:-}");
expect(compose).toContain("POLICY_AUD=${POLICY_AUD:-}");
expect(compose).toContain("CLOUDFLARE_INCLUDE_PROCESS_ENV=true");
expect(compose).not.toContain("AUTH_MODE=local_noauth");
});

it("documents Cloudflare Access as the first-class Docker auth path", () => {
const docs = readFileSync(resolve("docs/SELF_HOSTING_DOCKER.md"), "utf8");

expect(docs).toContain("AUTH_MODE=cloudflare_access");
expect(docs).toContain("TEAM_DOMAIN");
expect(docs).toContain("POLICY_AUD");
expect(docs).toContain("local_noauth");
expect(docs).toContain("trusted local development");
});
});
45 changes: 45 additions & 0 deletions src/lib/self-host-auth-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { getAuthMode } from "./auth-mode";

type SelfHostedAuthEnv = {
AUTH_MODE?: string | null;
TEAM_DOMAIN?: string | null;
POLICY_AUD?: string | null;
};

const CLOUDFLARE_ACCESS_MISSING_MESSAGE =
"AUTH_MODE=cloudflare_access requires TEAM_DOMAIN and POLICY_AUD";

function hasValue(value: string | null | undefined) {
return typeof value === "string" && value.trim().length > 0;
}

export function getSelfHostedAuthConfigMessage(
env: SelfHostedAuthEnv,
): string | null {
const authMode = getAuthMode(env.AUTH_MODE);

if (authMode !== "cloudflare_access") {
return null;
}

const missing = [
hasValue(env.TEAM_DOMAIN) ? null : "TEAM_DOMAIN",
hasValue(env.POLICY_AUD) ? null : "POLICY_AUD",
].filter(Boolean);

if (missing.length === 0) {
return null;
}

return `${CLOUDFLARE_ACCESS_MISSING_MESSAGE}. Missing: ${missing.join(
", ",
)}. Set AUTH_MODE=local_noauth only for trusted local development.`;
}

export function assertSelfHostedAuthConfig(env: SelfHostedAuthEnv) {
const message = getSelfHostedAuthConfigMessage(env);

if (message) {
throw new Error(message);
}
}
7 changes: 7 additions & 0 deletions src/middleware/ensure-user/cloudflareAccess.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { env } from "cloudflare:workers";
import { createRemoteJWKSet, jwtVerify } from "jose";
import { getSelfHostedAuthConfigMessage } from "@/lib/self-host-auth-config";
import { AppError } from "@/server/lib/errors";
import { resolveDelegatedContext } from "./delegated";
import type { EnsuredUserContext } from "./types";
Expand Down Expand Up @@ -46,6 +47,12 @@ function getValidatedTeamDomain(teamDomain: string) {
export async function resolveCloudflareAccessContext(
headers: Headers,
): Promise<EnsuredUserContext> {
const configMessage = getSelfHostedAuthConfigMessage(env);

if (configMessage) {
throw new AppError("AUTH_CONFIG_MISSING", configMessage);
}

const teamDomain = env.TEAM_DOMAIN
? getValidatedTeamDomain(env.TEAM_DOMAIN)
: null;
Expand Down
10 changes: 10 additions & 0 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { requestWithPublicOrigin } from "@/server/mcp/public-origin";
import { MCP_ROUTE } from "@/server/mcp/context";
import { handleSelfHostedOpenSeoMcpRequest } from "@/server/mcp/transport";
import { computeNextCheckAt } from "@/shared/rank-tracking";
import { getSelfHostedAuthConfigMessage } from "@/lib/self-host-auth-config";

const appFetch = createStartHandler(defaultStreamHandler);
const handleAppFetch = (request: Request): Response | Promise<Response> =>
Expand All @@ -29,6 +30,15 @@ function fetch(
const authMode = getAuthMode(env.AUTH_MODE);
const publicRequest = requestWithPublicOrigin(request);

const selfHostedAuthConfigMessage = getSelfHostedAuthConfigMessage(env);

if (selfHostedAuthConfigMessage) {
return new Response(selfHostedAuthConfigMessage, {
status: 500,
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
}

if (isHostedAuthMode(authMode)) {
return openSeoOAuthProvider.fetch(
publicRequest,
Expand Down
23 changes: 23 additions & 0 deletions src/server/mcp/transport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,12 @@ const transportOptionsSchema = z.object({
props: z.record(z.string(), z.unknown()),
})
.optional(),
corsOptions: z
.object({
headers: z.string().optional(),
exposeHeaders: z.string().optional(),
})
.optional(),
}),
});

Expand Down Expand Up @@ -155,6 +161,23 @@ describe("handleSelfHostedOpenSeoMcpRequest", () => {
});
});

it("allows the Cloudflare Access assertion header on self-hosted MCP CORS", async () => {
const { handleSelfHostedOpenSeoMcpRequest } =
await import("@/server/mcp/transport");

const response = await handleSelfHostedOpenSeoMcpRequest(
createMcpRequest(),
"cloudflare_access",
{},
ctx,
);
const body = transportOptionsSchema.parse(await response.json());

expect(body.options.corsOptions?.headers).toContain(
"cf-access-jwt-assertion",
);
});

it("lets the MCP transport handle OPTIONS without auth context", async () => {
const { handleSelfHostedOpenSeoMcpRequest } =
await import("@/server/mcp/transport");
Expand Down
2 changes: 1 addition & 1 deletion src/server/mcp/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ function handleOpenSeoMcpRequest(
authContext: props ? { props } : undefined,
corsOptions: {
headers:
"Authorization, Content-Type, Last-Event-ID, mcp-protocol-version, mcp-session-id",
"Authorization, Content-Type, Last-Event-ID, cf-access-jwt-assertion, mcp-protocol-version, mcp-session-id",
exposeHeaders: "mcp-protocol-version, mcp-session-id",
},
});
Expand Down