Troubleshooting
API won't start: "dev auth bypass must not be enabled". API_DEV_AUTH_BYPASS=true
with API_ENV != development. Set the bypass to false in staging/production.
/readyz returns 503. Postgres is unreachable — check DATABASE_URL, network, and
that migrations ran.
401 on /v1/... in development. Provide the X-Debug-Actor JSON header (dev only) or
a valid bearer token in production.
A portal's login goes nowhere and its logs show nothing. Almost always a missing Keycloak
redirect URI: Keycloak rejects an unregistered redirect_uri on its own error page, so the
container passes its healthcheck and the deployment looks entirely healthy while that one
portal has no working login. All eight portals' callbacks must be registered on the realm.
invalid_request: Missing parameter: code_challenge_method on sign-in. Expected since
2026-08-28: PKCE S256 is now enforced at the realm rather than merely sent. A client that
does not send a challenge is refused. Similarly, a password-grant (ROPC) call now answers
unauthorized_client: Client not allowed for direct access grants — the grant was disabled
deliberately. scripts/synthetic-journey.sh is a known casualty.
429 responses. Rate limit exceeded; back off and retry (honor Retry-After).
If you are getting 429s you did not earn, check API_TRUSTED_PROXY_CIDRS on the api
service before tuning the limits. Without it the API sees only the reverse proxy's address,
so every "per client IP" limiter is one global bucket shared by the whole internet, and one
noisy client throttles everybody (ADR-030). The API logs a warning naming the variable at every
startup while it is unset; when it is set, the startup line reads
trusted proxies: X-Forwarded-For honoured from these blocks only. Note that setting it in
Coolify is not sufficient on its own — Compose forwards only what it names, so the variable
must also appear under the api service in the compose file.
Buckets are still per process; a Redis-backed shared limiter for multi-instance does not exist yet.
Docs build fails on a broken link. onBrokenLinks: throw is intentional — fix the
link so documentation never silently diverges. This is a real check and it runs in
apps/docs/Dockerfile, so a broken internal link stops that image from building.