Skip to content

Self-hosting

Email

Sign-in is by emailed code, so mail has to work: SMTP for self-hosted, and the console transport for evaluation.

critlab has no passwords. Everyone signs in with a six-digit code sent by email (or through SSO), so a working mail transport is part of a production install. Mail also carries invitations and notification emails.

Pick a transport

web chooses the first one that is configured:

OrderSetTransportUse it for
1RESEND_API_KEYResend's HTTP APIcritlab cloud. A self-hosted install that never sets it never contacts Resend.
2SMTP_URLAny SMTP relaySelf-hosted and air-gapped installs.
3neitherConsoleEvaluation only: messages are printed to web's log, not sent.

MAIL_FROM sets the sender (default critlab@localhost, which real mail servers will reject). Use an address your relay may send as, e.g. critlab <critlab@example.com>.

SMTP

text
SMTP_URL=smtp://critlab%40example.com:app-password@smtp.example.com:587
MAIL_FROM=critlab <critlab@example.com>

SMTP_URL is a nodemailer connection URL: smtp:// for plain or STARTTLS (port 587 upgrades when the server offers it), smtps:// for implicit TLS on port 465. URL-encode @ and other reserved characters in the user name and password. Keep the whole URL in a Secret — it contains the password. With the Helm chart, set secrets.smtpUrl or add SMTP_URL to your own Secret and reference it through web.extraEnv, and set config.mailFrom.

The connection is opened lazily on the first send, so a wrong URL shows up when someone requests a code, not at startup. Check web's log for the error.

For deliverability, publish SPF and DKIM for the sending domain the way your relay's documentation describes; sign-in codes are time-sensitive and should not land in spam.

Console transport (evaluation)

With neither variable set, every message — sign-in codes and invitation links included — is printed to web's log in a box:

text
┌─ [critlab:mail] console transport — NOT sent, printed only ─────────────
│ from:    critlab@localhost
│ to:      ana@example.com
│ subject: …
│
│ …
└─ set RESEND_API_KEY or SMTP_URL to send this for real ─────────────────

That makes a fresh evaluation install usable without a mail server: sign up, then copy the code from the log (kubectl logs deploy/critlab-web or the pnpm dev output). Do not run production this way; anyone with log access can read sign-in codes.

How sign-in codes behave

  • A code is valid for 10 minutes and allows 5 attempts; only a hash is stored.
  • The same request is answered identically for known and unknown addresses, so the sign-in form cannot be used to find out who has an account.
  • Sending is limited per address — 30 seconds between codes and at most 6 an hour — on top of a per-IP limit.
  • Signing up and signing in are the same flow: entering a valid code for a new address creates the account.

AUTH_REQUIRE_EMAIL_VERIFICATION=true additionally blocks sign-in for accounts whose address is not verified. Accounts created through SSO or SCIM do not use codes at all.

Troubleshooting

SymptomCheck
No code arrivesThe relay refused or SMTP_URL is wrong: look for the send error in web's log.
Code printed in the log instead of sentNeither SMTP_URL nor RESEND_API_KEY is set on the web process.
Relay rejects the senderMAIL_FROM is still critlab@localhost or not an address the relay allows.
The form asks you to wait before sending another codeThe 30-second per-address cooldown, or the cap of 6 codes an hour.