Self-Hosting
Esta página aún no está disponible en tu idioma.
You can run Procaptcha entirely on your own infrastructure using the published prosopo/provider Docker image. You do
not need to fork the repository, edit any TypeScript, or produce an image dataset.
The short version: pick pow or puzzle as your CAPTCHA type, and the provider serves challenges on its own with
nothing else attached.
What you get
Section titled What you getServing challenges is one half of bot protection. The other half is deciding who should be challenged or blocked in the first place — continuously scoring traffic, running machine learning over it, and acting on the result. Self-hosting gives you the first half. The managed Professional and Enterprise tiers give you both.
| Self-hosted | Professional / Enterprise | |
|---|---|---|
Proof of Work (pow) | Yes | Yes |
Slider puzzle (puzzle) | Yes | Yes |
Image CAPTCHA (image) | Yes, with a dataset you build | Yes |
| Client-side bot detection | Yes, with a detector you build, such as the BotD example | Yes, ours |
| ML scoring and automatic blocking | No | Yes |
| Access rules, traffic filters, analytics | No | Yes |
pow needs no imagery at all, and puzzle generates its imagery procedurally at request time, so both work out of the
box. image is also available to you — it just needs a dataset you build and host yourself, so it is covered
further down rather than in the main flow.
Self-hosting is the right choice when you want a privacy-preserving, GDPR-friendly challenge on your forms and you control your own infrastructure. It is not the right choice when you are being actively targeted by adaptive bots — that is what the managed tiers are for.
Before you start
Section titled Before you start- A host with Docker and Docker Compose, and at least 4 GB of RAM.
- A public domain name pointing at that host, with ports 80 and 443 reachable from the internet. The provider itself speaks plain HTTP on 9229 and obtains no certificates; the bundled Caddy container does that for it.
- Node.js 24 and pnpm (via
corepack enable), to generate accounts and build the widget.
You don’t need to write a reverse-proxy config. The repository ships the one we run in production at
docker/provider.Caddyfile, and it is driven
entirely by environment variables — the compose file below wires it up.
1. Clone and generate your environment file
Section titled 1. Clone and generate your environment fileClone with submodules, install, and build the workspace once — the env script and every later step use the built packages. Then one script writes the whole environment file: it generates the provider account, the site key account and strong random passwords for Mongo and Redis, and fills in every other value from the hostname you give it.
git clone --recurse-submodules https://github.com/prosopo/captchacd captchacorepack enablepnpm install --frozen-lockfilepnpm run build:all
pnpm exec tsx ./dev/scripts/src/scripts/generateSelfHostedEnv.ts --host captcha.example.com{"scope":"generateSelfHostedEnv","level":"info","data":{"siteKeyAddress":"5FpdLFe3Mhi1JSo3vyu1UheV7vG5kFYfiWfNvbLCA2a2W4Sz","providerAddress":"5GnjcGsBoGLrVMhmfnkdXcTtACSGWhxokSx71U3PaWLRg1ti"},"msg":"Accounts generated"}{"scope":"generateSelfHostedEnv","level":"info","data":{"outputPath":"/home/you/captcha/.env"},"msg":"Environment file written"}Anything printed above those two lines, such as a @polkadot/util has multiple versions warning or
[AsyncFunction: makeDirectory], is noise from the build tooling — the script has not failed.
Note the siteKeyAddress. That is the public data-sitekey value you put on your web pages and register in
step 3. You can read it back out of the file at any time:
grep PROSOPO_SITE_KEY= .env.env is written with 0600 permissions because it holds two mnemonics and both database passwords. Keep
it out of version control, and back it up — regenerating it gives your provider a new identity and new credentials for
a database that still has the old ones.
Options: --output <path> to write somewhere else, --force to overwrite an existing file.
2. Start the stack
Section titled 2. Start the stackThe compose file, docker-compose.self-hosted.yml, ships with the repo. Your .env sets COMPOSE_FILE to it, so this
and every later docker compose command finds it without -f. Run them from the repository root.
docker compose up -dcurl localhost:9229/healthz # the provider directlycurl https://captcha.example.com/healthz # through Caddy, once the certificate is issuedA healthy provider replies {"ok":true,"host":"captcha.example.com"}.
Four containers — Mongo for storage, Redis for sessions and access rules, the provider, and Caddy terminating TLS in
front of it. Redis must be redis-stack rather than stock Redis: the provider stores access rules in a RediSearch
index. Caddy is prosopo/caddy rather than upstream Caddy, because
docker/provider.Caddyfile — the config we
run in production — needs four plugins compiled in (caddy-l4, chaddy, caddy-ratelimit, caddy-requestid).
PROSOPO_PROVIDER_IMAGE_VERSION in your .env is set to the release your checkout documents, so the image and these
instructions stay in step. When you pull a newer checkout, change it to match. CADDY_IMAGE_VERSION pins Caddy the same
way.
Two lines in the startup log are expected on a fresh node:
bundle pool loaded {"count":0}No datasets found in database. Image captchas will not work until a dataset is uploaded.The first means no bot detector is loaded yet, the second means no image dataset. Neither affects pow or puzzle.
The 9229 port mapping is only there so you can curl the provider directly while setting up; real traffic arrives
through Caddy on 443. Drop it once you’re happy.
3. Register your site key
Section titled 3. Register your site keyThis is the step that makes everything else unnecessary. Registering a site key with an explicit captcha_type tells
the provider to serve that challenge directly, skipping bot detection entirely:
docker compose exec provider /nodejs/bin/node /usr/src/app/provider.cli.bundle.js site_key_register \ "$(grep '^PROSOPO_SITE_KEY=' .env | cut -d= -f2)" enterprise \ --captcha_type puzzle \ --domains example.com \ --pow_difficulty 4 \ --frictionless_threshold 0.5 \ --image_threshold 0.8Use --captcha_type pow for the invisible proof-of-work challenge instead.
--pow_difficulty, --frictionless_threshold and --image_threshold are required by the command even when they do not
apply to your chosen type; the values above are safe defaults.
The tier is free, professional or enterprise. A self-hosted node enforces no request quota whatever you pick — the
only thing it changes is that free omits the score and reason fields from the verification response. Use
enterprise if you want them included.
To change the type later, run the same command again with a different --captcha_type.
4. Build the widget
Section titled 4. Build the widgetThe published bundle on Prosopo’s CDN is compiled to talk to Prosopo’s fleet, so build your own copy with
PROSOPO_PROVIDER_LIST baked in. This is a plain production build — you are not editing any source:
NODE_ENV=production \PROSOPO_PROVIDER_LIST=https://captcha.example.com \pnpm --filter @prosopo/procaptcha-bundle run bundleServe the whole of packages/procaptcha-bundle/dist/bundle/ from your own static hosting, then load it as a module.
The bundle is split into chunks that it imports, so type="module" is required and the files must be hosted together:
<script type="module" src="https://your-cdn.example.com/procaptcha.bundle.js" async defer></script><div class="procaptcha" data-sitekey="5Fpd..."></div>A widget built this way never contacts Prosopo: it skips the hosted provider list and the /healthz discovery call
and goes straight to the providers you named.
The CAPTCHA type is decided by the provider from the site key record, not by the page. A data-captcha-type attribute
on the element is ignored.
5. Verify tokens on your server
Section titled 5. Verify tokens on your serverWhen a user solves the challenge your form receives a procaptcha-response token. Verify it with
@prosopo/server, configured against your own provider:
PROSOPO_DEFAULT_ENVIRONMENT=productionPROSOPO_PROVIDER_LIST=https://captcha.example.comPROSOPO_SITE_KEY=5Fpd...PROSOPO_SITE_PRIVATE_KEY="..."All four are already in the .env you generated in step 1 — copy them across, or point your app at the
same file.
This is read at runtime, so no rebuild is needed if you add a provider later.
Every URL must match its CADDY_DOMAIN exactly. A token carries the provider URL it was minted against and the
verifier finds the issuer by exact string match, so a provider missing from this list makes its tokens unverifiable.
Trailing slashes are normalised for you, but nothing else is.
Bring your own detector
Section titled Bring your own detectorThe steps above serve the same challenge to everyone. To score visitors yourself, load a detector of your own and
register the site key as frictionless. The provider then picks a challenge per visitor from your detector’s score.
demos/detector-botd is a working example built on
BotD. It reports a score of 1 when BotD flags the browser as automated
(headless Chrome, Selenium, Playwright, PhantomJS and others) and 0 otherwise. It makes no requests to anyone but your
provider.
pnpm --filter @prosopo/detector-botd run buildpnpm --filter @prosopo/detector-botd run pool --out detector-pool.json
docker compose exec -T provider /nodejs/bin/node /usr/src/app/provider.cli.bundle.js detector_pool_set < detector-pool.jsondocker compose restart provider
docker compose exec provider /nodejs/bin/node /usr/src/app/provider.cli.bundle.js site_key_register \ "$(grep '^PROSOPO_SITE_KEY=' .env | cut -d= -f2)" enterprise \ --captcha_type frictionless \ --domains example.com \ --pow_difficulty 4 \ --frictionless_threshold 0.5 \ --frictionless_image_threshold 2 \ --image_threshold 0.8After the restart the provider log shows bundle pool loaded {"count":4}.
pool builds four bundles, each with its own RSA keypair: the public key goes into the bundle’s JavaScript and the
private key stays on your provider. You never handle the encryption yourself. The provider hands each visitor one of
the bundles, and the detector encrypts what it reports with that bundle’s key.
The pool is piped in on stdin rather than copied in as a file, because the provider container does not run as root and
could not read a copied file. It is kept in the detector_pool volume, so it survives restarts and upgrades. Run the
same commands again to replace it.
The score thresholds on the site key decide what each visitor gets:
| score | served |
|---|---|
below --frictionless_threshold (0.5) | invisible proof of work |
from there up to --frictionless_image_threshold | slider puzzle |
at or above --frictionless_image_threshold | image challenge |
The example sets the image threshold to 2, above any score BotD gives, so flagged browsers get a puzzle. Leave it at
its default of 1 only if you have loaded an image dataset — without one, a visitor routed to an
image challenge gets an error.
Writing your own
Section titled Writing your ownReplace collectSignals in
demos/detector-botd/src/detector.ts
and rebuild the pool. It returns a score and a d object:
const collectSignals = async (): Promise<Signals> => ({ score: navigator.webdriver ? 1 : 0, d: { webDriver: navigator.webdriver, languages: navigator.languages.length },});Everything in d is stored on the session and passed to decision and routing machines as input.d, so a rule can act
on input.d.botKind or any key you add. Nothing on the provider declares the keys. Values must be plain JSON: keys
containing . or starting with $ are dropped, and a d larger than 8 KB is dropped whole.
The rest of the file handles the contract with the widget and the provider — the visitor’s account, hashes of the user
agent and of document.head, a timestamp, and the encryption. Leave it as it is.
What a detector you build can and cannot do
Section titled What a detector you build can and cannot doYour detector runs in the visitor’s browser, and its public key ships inside the bundle, so anyone who reads the bundle can send whatever score they like. The provider checks that a report was made for this visitor, this browser and this page within the last ten minutes. It cannot check that the score is honest. That is true of every client-side detector; ours resists it with obfuscation and per-bundle rotation, which the example does not attempt.
So a detector like this one stops off-the-shelf automation — default headless browsers and frameworks that make no effort to hide — and gives your rules signals to work with. It will not stop someone who studies your bundle and targets your site.
Detectors built this way only load on a provider with PROSOPO_DETECTOR_OPEN_FORMAT=1. The self-hosted compose file
sets it for you.
What self-hosting does not give you
Section titled What self-hosting does not give youA self-hosted node serves challenges and validates solutions. Unless you load a detector, it does not decide who is a bot.
Our client-side detector. Our frictionless scoring uses a detector that is the one closed-source part of the stack
and is not in the public image. This does not block you: an explicit captcha_type bypasses the scoring path altogether, so pow and
puzzle behave exactly as they do on the managed service, and a site key left on frictionless against a detector-less
provider falls back to serving proof of work.
The detection layer. On Professional and Enterprise, every session feeds a pipeline that scores traffic, runs
machine learning over it, and blocks or challenges suspicious visitors automatically — along with the access rules,
traffic filters and analytics you use to tune it. None of that runs on a self-hosted node. A self-hosted pow or
puzzle challenge is a fixed obstacle in front of your form: good against opportunistic and low-effort automation,
but it does not adapt, and a determined attacker only has to solve it once per request.
A detector of your own adds scoring on signals you choose. If you want ours on your own node, contact us — we supply obfuscated bundles for self-hosted deployments.
Image CAPTCHAs
Section titled Image CAPTCHAsimage works on a self-hosted node too — it just needs a dataset, which is why it is not in the main flow above. You
build one with @prosopo/datasets-fs; its README
documents every command.
The one thing worth knowing up front: a dataset does not contain images. Each entry holds a URL that the browser fetches while the user solves the challenge, so you host the images yourself on any static host or CDN and the dataset points at them.
Starting from a directory with one subdirectory per label:
pnpm --filter @prosopo/datasets-fs run buildcd packages/datasets-fs
# Collapse the tree into one image directory plus a label map.node dist/cli.js flatten --in ./data --out ./flat --overwritenode dist/cli.js labels --in ./flat/data.json --out ./labels.json --overwrite
# Upload ./flat/images somewhere public, then rewrite the paths to match.node dist/cli.js relocate --in ./flat/data.json --out ./data-hosted.json \ --from /abs/path/flat/images --to https://img.example.com --overwritenode dist/cli.js get --in ./data-hosted.json # confirms every URL resolves
node dist/cli.js generate-v2 --out ./captchas.json \ --labelled ./data-hosted.json --unlabelled ./data-hosted.json \ --labels ./labels.json --seed 0 --size 9 --count 100 \ --minCorrect 1 --minIncorrect 1 --minLabelled 2 --maxLabelled 7 \ --allowDuplicates --overwrite--count defaults to zero. Omit it and the command still succeeds, having written a dataset with no captchas in it.
Load it and switch the site key over:
docker compose cp ./captchas.json provider:/usr/src/app/captchas.jsondocker compose exec provider /nodejs/bin/node /usr/src/app/provider.cli.bundle.js provider_set_data_set --file /usr/src/app/captchas.jsondocker compose restart provider
docker compose exec provider /nodejs/bin/node /usr/src/app/provider.cli.bundle.js site_key_register "$(grep '^PROSOPO_SITE_KEY=' .env | cut -d= -f2)" enterprise \ --captcha_type image --domains example.com \ --pow_difficulty 4 --frictionless_threshold 0.5 --image_threshold 0.8The restart is not optional. The provider resolves its default dataset once at startup, so a dataset uploaded into a
running provider is stored but not served, and image challenges keep failing with No dataset available until the
process restarts.
Image asset signing
Section titled Image asset signingThe provider can rewrite captcha image URLs into short-lived signed or sealed URLs at serve time, so a URL harvested from a challenge dies within its TTL instead of being refetchable forever, offline and unattributably.
Both are opt-in and off unless you supply a key. A self-hosted dataset of plain https:// URLs works exactly as
described above: whatever URL is in your dataset is the URL the browser is handed. Verified against
prosopo/provider:3.8.19 — with no asset variables set, the image challenge returns the dataset URL verbatim, with no
token, no expiry and no rewritten path.
Turn it on only if your CDN supports the corresponding scheme:
| variable | effect |
|---|---|
PROSOPO_ASSET_TOKEN_KEY | Bunny-style token signing. Enables the feature. |
PROSOPO_ASSET_TOKEN_TTL_SECONDS | How long a signed URL stays valid. |
PROSOPO_ASSET_TOKEN_BIND_IP | Also bind the URL to the requesting IP. Must match the pull zone’s ZoneSecurityIncludeHashRemoteIP exactly or every image 403s, and it breaks clients whose images egress from a different address than their API call. |
PROSOPO_ASSET_SEAL_KEYS | Opaque sealed paths opened at the edge, with PROSOPO_ASSET_SEAL_ACTIVE_KEY_ID, PROSOPO_ASSET_SEAL_TTL_SECONDS and PROSOPO_ASSET_SEAL_URL_PREFIX. |
Signing happens strictly below item.data — the item hash, the captchaId and the datasetId are untouched — so turning
it on later changes nothing about an existing dataset or its verification.
Running more than one provider
Section titled Running more than one providerPROSOPO_PROVIDER_LIST takes a list, so scaling out is a matter of naming the extra nodes. Comma-separated URLs are
enough when you just want traffic spread evenly:
PROSOPO_PROVIDER_LIST=https://captcha1.example.com,https://captcha2.example.comThe widget picks one per session and, if that node errors, retries against a different one. For uneven capacity, use
the same JSON the hosted provider list serves and give each node a weight from 1 to 100:
PROSOPO_PROVIDER_LIST={"one":{"address":"5Abc...","url":"https://captcha1.example.com","datasetId":"","weight":3},"two":{"address":"5Def...","url":"https://captcha2.example.com","datasetId":"","weight":1}}Here the first node takes about three quarters of the traffic. The format is documented in full in the
@prosopo/load-balancer README.
Every node needs its own CADDY_DOMAIN and certificate, and they can share one Mongo and Redis or run their own —
sessions are looked up by the node that created them, so a shared Redis is only needed if you want a session to survive
being routed to a different node mid-challenge.
Rebuild the widget whenever the list changes, and update your verifier’s copy too.
Optional extras
Section titled Optional extras- Database indexes. Mongo creates these automatically, but you can force them with
docker compose exec provider /nodejs/bin/node /usr/src/app/provider.cli.bundle.js ensure_indexes. - Upgrades. Pull a newer checkout, set
PROSOPO_PROVIDER_IMAGE_VERSIONin.envto match, and rundocker compose up -d. Rebuild the widget when you upgrade so the two stay on the same release.