Skip to content
Merged
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
17 changes: 17 additions & 0 deletions .changeset/self-hosted-cloudflare.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"@prosopo/scripts": patch
---

Document and support self-hosting on Cloudflare's free plan, for anyone without a server that has a public IP.

A Worker on the free `workers.dev` subdomain serves the widget and forwards API calls through a Workers VPC binding to a
Cloudflare Tunnel, so the provider can run on any machine with Docker: no domain, no open ports, no certificates.

- `docker/cloudflare/worker.ts` and `wrangler.jsonc`: the Worker. It also serves a demo page at `/` that shows whether
the provider is reachable, renders the CAPTCHA and displays the response token. It sets `X-Forwarded-For` to the
visitor's address, because without it the provider sees every visitor as the tunnel and per-IP limits apply to all of
them together.
- `docker-compose.self-hosted.cloudflare.yml`: adds `cloudflared` and leaves Caddy out, since Cloudflare terminates TLS.
- `generateSelfHostedEnv.ts --cloudflare`: writes a `COMPOSE_FILE` that loads both compose files and a
`CLOUDFLARE_TUNNEL_TOKEN` to fill in.
- `SELF_HOSTING.md`: a "No server? Run it on Cloudflare's free plan" section.
78 changes: 78 additions & 0 deletions SELF_HOSTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,84 @@ The CAPTCHA type comes from the site key record, not the page.
The bundle is an ES module split into chunks, so `type="module"` is required and the whole `dist/bundle/` directory must
be hosted together.

## No server? Run it on Cloudflare's free plan

Steps 2 and 4 assume a server with a public IP and ports 80 and 443 open. If you don't have one, any machine that can
run Docker will do — a spare PC, a home server — with Cloudflare in front of it. You need a free Cloudflare account and
nothing else: no domain, no public IP, no open ports, no certificates.

One Worker on your free `workers.dev` subdomain serves the widget and forwards API calls to your provider through a
Cloudflare Tunnel. The tunnel connects outbound from your machine, so nothing is exposed on your network. Cloudflare
handles HTTPS, so Caddy is not used.

Do step 1 up to `pnpm run build:all`, but don't generate the environment file yet — you'll generate it below with
the Worker's hostname. Then, from the repository root:

```bash
npx wrangler@4.151.3 login
npx wrangler@4.151.3 tunnel create procaptcha # note the ID it prints
```

Open the tunnel in the Cloudflare dashboard (**Zero Trust → Networks → Tunnels → procaptcha**). Its install command
contains `--token <token>`; copy the token. You don't need to add a public hostname.

Your Worker will be at `procaptcha.<subdomain>.workers.dev`, where `<subdomain>` is shown in the dashboard under
**Workers & Pages**. Generate the environment file for that hostname, with `--cloudflare`:

```bash
pnpm exec tsx ./dev/scripts/src/scripts/generateSelfHostedEnv.ts \
--host procaptcha.<subdomain>.workers.dev --cloudflare
```

Paste the token into `CLOUDFLARE_TUNNEL_TOKEN` in `.env`, then start the stack. `--cloudflare` has already set
`COMPOSE_FILE` to add [`docker-compose.self-hosted.cloudflare.yml`](docker-compose.self-hosted.cloudflare.yml), which
runs `cloudflared` and leaves Caddy out:

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

Let Workers reach the provider through the tunnel, and put the service ID it prints into
[`docker/cloudflare/wrangler.jsonc`](docker/cloudflare/wrangler.jsonc) in place of `REPLACE_WITH_YOUR_VPC_SERVICE_ID`:

```bash
npx wrangler@4.151.3 vpc service create procaptcha-provider \
--type http --tunnel-id <tunnel ID> --hostname provider --http-port 9229
```

Register your site key as in [step 3](#3-register-a-site-key), adding the Worker's hostname to `--domains` so its demo
page works: `--domains example.com procaptcha.<subdomain>.workers.dev`.

Build the widget against the Worker and deploy it:

```bash
NODE_ENV=production \
PROSOPO_PROVIDER_LIST=https://procaptcha.<subdomain>.workers.dev \
pnpm --filter @prosopo/procaptcha-bundle run bundle

npx wrangler@4.151.3 deploy --config docker/cloudflare/wrangler.jsonc
```

Open `https://procaptcha.<subdomain>.workers.dev/?sitekey=<your site key>`. The demo page shows whether the provider is
reachable, renders the CAPTCHA, and shows the response token once it is solved. On your own site, load the widget from
the Worker:

```html
<script type="module" src="https://procaptcha.<subdomain>.workers.dev/procaptcha.bundle.js" async defer></script>
<div class="procaptcha" data-sitekey="5Fpd..."></div>
```

Server-side verification is unchanged; `PROSOPO_PROVIDER_LIST` in your `.env` already points at the Worker.

Worth knowing before you rely on it:

- Workers VPC, which lets the Worker reach the tunnel, is in open beta.
- The free Workers plan has a daily request allowance. Each solve is a handful of requests, so check the current limit
against your traffic.
- The Worker passes each visitor's address to the provider in `X-Forwarded-For`. Without that, the provider would see
every visitor as the tunnel, and per-IP rate limits and rules would apply to all of them together.
- The provider only runs while your machine does.

## Bring your own detector

The steps above serve a fixed challenge to everyone. To score visitors yourself, load a detector of your own and register
Expand Down
28 changes: 23 additions & 5 deletions dev/scripts/src/scripts/generateSelfHostedEnv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ interface Args {
host: string;
output: string;
force: boolean;
cloudflare: boolean;
}

const usage = `
Expand All @@ -38,6 +39,9 @@ Options:
--host <domain> Hostname clients will reach this provider on (required)
--output <path> Where to write (default: .env)
--force Overwrite the output file if it already exists
--cloudflare Run behind a Cloudflare Tunnel instead of Caddy. Adds the
cloudflared service and a CLOUDFLARE_TUNNEL_TOKEN to fill in.
--host is then the Worker's workers.dev hostname.
`;

const parseArgs = (argv: string[]): Args => {
Expand Down Expand Up @@ -65,6 +69,7 @@ const parseArgs = (argv: string[]): Args => {
host,
output: flagValue("--output") ?? ".env",
force: argv.includes("--force"),
cloudflare: argv.includes("--cloudflare"),
};
};

Expand All @@ -76,7 +81,22 @@ const parseArgs = (argv: string[]): Args => {
*/
const generatePassword = (): string => randomBytes(24).toString("base64url");

const buildEnvFile = async (host: string): Promise<string> => {
const composeFileSection = (cloudflare: boolean): string =>
cloudflare
? `# Lets every docker compose command find both compose files without -f.
# The second runs cloudflared and leaves Caddy out.
COMPOSE_FILE=docker-compose.self-hosted.yml:docker-compose.self-hosted.cloudflare.yml

# The token from your tunnel's page in the Cloudflare dashboard.
CLOUDFLARE_TUNNEL_TOKEN=`
: `# Lets every docker compose command find docker-compose.self-hosted.yml
# without -f.
COMPOSE_FILE=docker-compose.self-hosted.yml`;

const buildEnvFile = async (
host: string,
cloudflare: boolean,
): Promise<string> => {
const providerImageVersion = readProviderImageVersion();
const [providerMnemonic, providerAddress] = await generateMnemonic();
const [siteKeyMnemonic, siteKeyAddress] = await generateMnemonic();
Expand All @@ -95,9 +115,7 @@ NODE_ENV=production
PROSOPO_DEFAULT_ENVIRONMENT=production
PROSOPO_LOG_LEVEL=info

# Lets every docker compose command find docker-compose.self-hosted.yml
# without -f.
COMPOSE_FILE=docker-compose.self-hosted.yml
${composeFileSection(cloudflare)}

# The provider release this checkout documents. Change it when you pull a
# newer checkout, so the image and the instructions you follow stay in step.
Expand Down Expand Up @@ -181,7 +199,7 @@ const main = async (): Promise<void> => {
);
}

const contents = await buildEnvFile(args.host);
const contents = await buildEnvFile(args.host, args.cloudflare);
// 0o600 because this file holds two mnemonics and two passwords.
fs.writeFileSync(outputPath, contents, { mode: 0o600 });

Expand Down
28 changes: 28 additions & 0 deletions docker-compose.self-hosted.cloudflare.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Runs the self-hosted provider behind a Cloudflare Tunnel, for hosts with no
# public address. Cloudflare terminates TLS, so Caddy is not started.
#
# Used on top of docker-compose.self-hosted.yml. generateSelfHostedEnv.ts
# --cloudflare writes a COMPOSE_FILE that loads both.
#
# Guide: "Self-hosting on Cloudflare" in SELF_HOSTING.md.

services:
caddy:
# Only started with --profile caddy, which a tunnelled node never needs.
profiles: ['caddy']

cloudflared:
image: cloudflare/cloudflared:2026.10.3
restart: unless-stopped
depends_on:
- provider
command: tunnel --no-autoupdate run
environment:
# Passed in the environment rather than on the command line so it does
# not show in the process list.
TUNNEL_TOKEN: ${CLOUDFLARE_TUNNEL_TOKEN:?paste your tunnel token into CLOUDFLARE_TUNNEL_TOKEN in .env}
logging:
driver: 'json-file'
options:
max-size: '100m'
max-file: '1'
169 changes: 169 additions & 0 deletions docker/cloudflare/worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
// Copyright 2021-2026 Prosopo (UK) Ltd.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

// Fronts a self-hosted provider that has no public address of its own. The
// widget is served as static assets, and provider API calls are forwarded
// through a Workers VPC binding to the Cloudflare Tunnel the provider sits
// behind.

interface Binding {
fetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response>;
}

interface Env {
ASSETS: Binding;
PROVIDER: Binding;
SITE_KEY: string;
}

const isProviderPath = (pathname: string): boolean =>
pathname.startsWith("/v1/") || pathname === "/healthz";

// The provider reads the client address from the last X-Forwarded-For entry.
// Without this it would see every visitor as the tunnel, and per-IP rate
// limits and access rules would apply to all of them at once.
const forwardToProvider = (
request: Request,
url: URL,
env: Env,
): Promise<Response> => {
const headers = new Headers(request.headers);
const visitorIp = request.headers.get("CF-Connecting-IP");
if (visitorIp) headers.set("X-Forwarded-For", visitorIp);
headers.set("X-Forwarded-Proto", "https");
return env.PROVIDER.fetch(`http://provider${url.pathname}${url.search}`, {
method: request.method,
headers,
body: request.body,
redirect: "manual",
});
};

const escapeHtml = (value: string): string =>
value.replace(
/[&<>"']/g,
(char) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[
char
] ?? char,
);

const SITE_KEY_PATTERN = /^[1-9A-HJ-NP-Za-km-z]{47,48}$/;

const demoPage = (siteKey: string, host: string): string => `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Procaptcha · self-hosted</title>
<script type="module" src="/procaptcha.bundle.js" async defer></script>
<style>
:root {
color-scheme: light dark;
--bg: #f5f6f8; --card: #ffffff; --text: #1d2330; --muted: #5d6677;
--line: #e2e5eb; --accent: #4b3fe0; --ok: #1f8a5b; --warn: #b26a00;
--code: #f0f1f4;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #12141a; --card: #1b1e26; --text: #e8eaf0; --muted: #a0a7b6;
--line: #2c303b; --accent: #8f87ff; --ok: #4cc38a; --warn: #f0a640;
--code: #242833;
}
}
* { box-sizing: border-box; }
body {
margin: 0; min-height: 100vh; background: var(--bg); color: var(--text);
font: 16px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
display: grid; place-items: center; padding: 32px 16px;
}
main {
width: 100%; max-width: 560px; background: var(--card);
border: 1px solid var(--line); border-radius: 14px; padding: 32px;
box-shadow: 0 1px 2px rgb(0 0 0 / 4%), 0 8px 24px rgb(0 0 0 / 6%);
}
h1 { font-size: 22px; margin: 0 0 4px; }
.lede { color: var(--muted); margin: 0 0 24px; }
dl { display: grid; grid-template-columns: max-content 1fr; gap: 8px 16px; margin: 0 0 24px; font-size: 14px; }
dt { color: var(--muted); }
dd { margin: 0; min-width: 0; }
code { background: var(--code); border-radius: 6px; padding: 2px 6px; font-size: 13px; overflow-wrap: anywhere; }
.status::before { content: "●"; margin-right: 6px; }
.status.ok { color: var(--ok); } .status.warn { color: var(--warn); }
.captcha { border: 1px dashed var(--line); border-radius: 10px; padding: 16px; margin-bottom: 24px; display: flex; justify-content: center; }
.token-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; font-size: 14px; color: var(--muted); }
button {
font: inherit; font-size: 13px; color: var(--accent); background: none;
border: 1px solid var(--line); border-radius: 8px; padding: 4px 10px; cursor: pointer;
}
button:disabled { opacity: 0.4; cursor: default; }
pre {
margin: 0; background: var(--code); border-radius: 10px; padding: 12px;
font-size: 12px; white-space: pre-wrap; word-break: break-all; max-height: 160px; overflow: auto;
}
.missing { color: var(--warn); }
footer { margin-top: 24px; font-size: 13px; color: var(--muted); }
a { color: var(--accent); }
</style>
</head>
<body>
<main>
<h1>Procaptcha, self-hosted</h1>
<p class="lede">This page, the widget and the provider API are all served by this Worker. Challenges are answered by your own provider through a Cloudflare Tunnel.</p>
<dl>
<dt>Provider</dt><dd><code>https://${escapeHtml(host)}</code></dd>
<dt>Status</dt><dd><span id="status" class="status">checking…</span></dd>
<dt>Site key</dt><dd>${siteKey ? `<code>${escapeHtml(siteKey)}</code>` : '<span class="missing">none — add <code>?sitekey=</code> to the URL, or set <code>SITE_KEY</code> in wrangler.jsonc</span>'}</dd>
</dl>
${siteKey ? `<form class="captcha"><div class="procaptcha" data-sitekey="${escapeHtml(siteKey)}"></div></form>` : ""}
<div class="token-head"><span>Response token</span><button id="copy" type="button" disabled>Copy</button></div>
<pre id="token">Solve the challenge above to get a token.</pre>
<footer>Verify the token on your server with <a href="https://www.npmjs.com/package/@prosopo/server">@prosopo/server</a>. Guide: <a href="https://docs.prosopo.io/en/self-hosting/">docs.prosopo.io/en/self-hosting</a></footer>
</main>
<script>
const status = document.getElementById("status");
fetch("/healthz")
.then((response) => response.ok ? response.json() : Promise.reject(response.status))
.then(() => { status.textContent = "provider reachable"; status.classList.add("ok"); })
.catch(() => { status.textContent = "provider not reachable — is the tunnel running?"; status.classList.add("warn"); });
const tokenBox = document.getElementById("token");
const copy = document.getElementById("copy");
copy.addEventListener("click", () => navigator.clipboard.writeText(tokenBox.textContent));
setInterval(() => {
const token = document.querySelector('[name="procaptcha-response"]')?.value;
if (token && tokenBox.textContent !== token) {
tokenBox.textContent = token;
copy.disabled = false;
}
}, 500);
</script>
</body>
</html>`;

export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url);
if (isProviderPath(url.pathname)) {
return forwardToProvider(request, url, env);
}
if (url.pathname === "/") {
const requested = url.searchParams.get("sitekey") ?? env.SITE_KEY;
const siteKey = SITE_KEY_PATTERN.test(requested) ? requested : "";
return new Response(demoPage(siteKey, url.host), {
headers: { "content-type": "text/html; charset=utf-8" },
});
}
return env.ASSETS.fetch(request);
},
};
24 changes: 24 additions & 0 deletions docker/cloudflare/wrangler.jsonc
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Deploys the Worker that fronts a self-hosted provider on Cloudflare's free
// plan. See "Self-hosting on Cloudflare" in SELF_HOSTING.md.
{
"name": "procaptcha",
"main": "worker.ts",
"compatibility_date": "2026-10-01",
"assets": {
// The widget, built with PROSOPO_PROVIDER_LIST pointing at this Worker.
"directory": "../../packages/procaptcha-bundle/dist/bundle",
"binding": "ASSETS",
"run_worker_first": ["/v1/*", "/healthz"]
},
"vpc_services": [
{
"binding": "PROVIDER",
// The id printed by `wrangler vpc service create`.
"service_id": "REPLACE_WITH_YOUR_VPC_SERVICE_ID"
}
],
"vars": {
// Shown on the demo page at /. Leave empty to pass ?sitekey= instead.
"SITE_KEY": ""
}
}
Loading