Behind a reverse proxy

Why TRUSTED_PROXIES is not optional, and the certificate failure a healthcheck cannot see.

Bollwark speaks plain HTTP and expects TLS to terminate in front of it.

Set TRUSTED_PROXIES

TRUSTED_PROXIES=172.16.0.0/12,10.0.0.0/8

This is not hygiene — it is what makes per-address scoring work at all.

The service resolves the client address by walking X-Forwarded-For right-to-left until it reaches an address outside the trusted list. With TRUSTED_PROXIES unset it uses the immediate peer instead, which behind a proxy means every visitor scores as the same address. That collapses the rate signal and will trip IP_HARD_LIMIT for your entire population at once.

The same list gates the TLS-fingerprint header (TLS_FINGERPRINT_HEADER, e.g. Cloudflare's cf-ja4), so a direct client cannot forge it.

Forward X-Forwarded-For and X-Forwarded-Proto from the proxy. Nothing else is required: the service is cookie-free, so there is no session affinity, SameSite handling or credentials configuration to get right, and cross-origin embeds work without any of it.

The certificate failure you won't notice

If your proxy manages Let's Encrypt certificates — Traefik, Caddy, Coolify — a failed ACME renewal can silently fall back to a self-signed certificate.

The failure mode is specific and nasty: the container keeps reporting healthy, because /healthz is answered from inside it. Meanwhile browsers refuse to load the widget script, and every embed on every site you protect breaks at once.

Verify the public URL with full chain validation, from outside. The repository ships scripts/check-public-endpoint.sh for this, wired up as a GitHub Actions workflow and as just monitor. Point it at your domain and let it run on a schedule.

Origin restrictions

Registering a site with allowed_origins limits which origins can request puzzles for that site_key:

bash
curl -s -X POST http://localhost:3000/v1/sites \
  -H "Authorization: Bearer $ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"name":"my-app","allowed_origins":["https://app.example.com"]}'

Each entry is a full origin — http(s)://host[:port], no path, no trailing slash. When the list is non-empty, GET /v1/puzzle answers 403 to a browser whose Origin is not on it. Requests with no Origin header at all — same-origin embeds, server-to-server calls — always pass.

This is tenant hygiene, not bot defense. It stops a third party embedding your public site_key on their own page and burning your quota. It isn't a security control: a non-browser client can forge the header, so the real trust boundary stays the secret_key at /v1/verify.

Change the list later without rotating the secret, via PUT /v1/admin/sites/{site_key}/origins.