Files
HiveGrid-Wireframes/hivegrid-cloud/infra/mcaptcha

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.md AD-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/CREDENTIALS on 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.

  1. DNS: A record captcha.saas-ctrl.com → 176.9.111.179 (Cloudflare, DNS-only so Caddy can issue the LE cert).
  2. Stack: copy docker-compose.yml to /opt/mcaptcha/, cp .env.example .env, set the three secrets (openssl rand -hex 32 for COOKIE_SECRET, SALT, and the PG password — also paste the PG password into DATABASE_URL). chmod 600 .env. Then docker compose up -d.
  3. Caddy: append Caddyfile.snippet to /etc/caddy/Caddyfile, caddy validate, systemctl reload caddy.
  4. Account + sitekeys (one-time): with MCAPTCHA_allow_registration=true, register the ops account via POST /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/create with {levels, duration, description, publish_benchmarks:false}). Then set MCAPTCHA_allow_registration=false and docker 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-data holds 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.