Bollwark speaks plain HTTP and expects TLS to terminate in front of it.
Set TRUSTED_PROXIES
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:
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.