Self-hosted mCaptcha
Proof-of-work CAPTCHA for the whole boilerplate (login, public forms, booking). One instance serves all services via per-surface sitekeys. EU-self-hosted, no third-party processor — replaces Cloudflare Turnstile / reCAPTCHA / hCaptcha.
Architecture decision: HiveGrid
docs/ARCHITECTURE-DECISIONS.mdAD-7. Stack:mcaptcha/mcaptcha(server, :7000) +postgres:16-alpine+mcaptcha/cache(Redis with mCaptcha's PoW module — not vanilla redis).
Status: DEPLOYED ✅ (2026-06-05)
Live at https://captcha.saas-ctrl.com on node1 (176.9.111.179), running as a
plain docker-compose stack at /opt/mcaptcha behind host-Caddy (same pattern as
/opt/sentry). Valid Let's Encrypt cert. Registration is closed.
- Ops account + account secret + the 3 sitekeys:
~/.cli-tokens/mcaptcha(and/opt/mcaptcha/CREDENTIALSon node1, chmod 600). - Sitekeys (public):
login,forms,booking.
Deploy / re-deploy on node1 (the actual setup)
node1 is not Coolify-managed; services are direct docker-compose stacks behind
host-level Caddy (/etc/caddy/Caddyfile, systemd) which terminates TLS.
- DNS: A record
captcha.saas-ctrl.com→176.9.111.179(Cloudflare, DNS-only so Caddy can issue the LE cert). - Stack: copy
docker-compose.ymlto/opt/mcaptcha/,cp .env.example .env, set the three secrets (openssl rand -hex 32for COOKIE_SECRET, SALT, and the PG password — also paste the PG password intoDATABASE_URL).chmod 600 .env. Thendocker compose up -d. - Caddy: append
Caddyfile.snippetto/etc/caddy/Caddyfile,caddy validate,systemctl reload caddy. - Account + sitekeys (one-time): with
MCAPTCHA_allow_registration=true, register the ops account viaPOST /api/v1/signup(username must be alphanumeric — PRECIS UsernameCaseMapped), sign in (/api/v1/signin, cookie auth over https — the session cookie is Secure + host-bound, so it won't work over http://localhost), read the account secret (GET /api/v1/account/secret/get), and create sitekeys (POST /api/v1/mcaptcha/createwith{levels, duration, description, publish_benchmarks:false}). Then setMCAPTCHA_allow_registration=falseanddocker compose up -d.
How the app consumes it
Set in the app's env (not this .env):
MCAPTCHA_INSTANCE_URL=https://captcha.saas-ctrl.com
MCAPTCHA_ACCOUNT_SECRET=<from ~/.cli-tokens/mcaptcha>
MCAPTCHA_SITEKEY_LOGIN=<sitekey_login>
MCAPTCHA_SITEKEY_FORMS=<sitekey_forms>
MCAPTCHA_SITEKEY_BOOKING=<sitekey_booking>
CAPTCHA_FAIL_OPEN=true
Verify flow: client solves the PoW widget → token → app server calls
POST {MCAPTCHA_INSTANCE_URL}/api/v1/pow/siteverify with { secret, token, key }
(handled by @toolctrl/captcha).
Fail policy
If the instance is unreachable, the app fails open (lets the submission through)
and logs a warning — login brute-force is still covered by auth-rate-limit +
shared-store. Flip to fail-closed via CAPTCHA_FAIL_OPEN=false.
Ops notes
- Only
mcaptcha-pg-dataholds state (account + sitekeys + stats); the cache is ephemeral. - mCaptcha terminates no TLS; Caddy must sit in front.
- Images are
:latest— pin to a released tag before relying on it long-term.