Skip to content

Environment

Every variable the relay reads, its default, and what it changes. apps/collab-server/src/config/config.ts parses them once at boot and refuses to start on a value it cannot read, so a wrong variable is a startup error naming itself, not a silent fallback. .env.example lists the same set, and compose.yaml passes all of it through.

Setting a variable is ../how-to/deploy.md; sizing the numbers is ../how-to/operate.md; the quotas are generated into limits.md.

What the panel manages, and what stays here

Section titled “What the panel manages, and what stays here”

A value the app can re-read on the next request belongs in the settings table, not in this file: @suite/settings holds the registry, and the operator panel at /admin edits it. Resolution is organization → global → environment → default, so a variable here is a boot default rather than a ceiling, and the panel shows which level answered.

Panel-managed Applies
Stripe keys, webhook secrets, price tiers, billing.mode next control-plane boot
SMTP host, port, user, password, From next control-plane boot
Licence public key and tier caps next boot
Backup bucket and endpoint next backup run
Flags next request

These stay in .env, because the process needs them before it can read a row, and the panel shows them read-only: SUITE_CONTROL_PLANE_DATABASE_URL, SUITE_SETTINGS_KEY (it decrypts the stored secrets), SUITE_AUTH_SECRET, SUITE_AUTH_URL, SUITE_ADMIN_EMAILS (the first operator has to be able to sign in), PORT and SUITE_DATA_DIR. pnpm setup generates them; pnpm doctor says which one is missing.

Variable Default Serves
PORT 1234 the Loro WebSocket sync server
HEALTH_PORT 1235 the HTTP API, /health, /metrics and the admin panel

The two must differ; anything else is refused at boot.

Six SQLite stores exist in code. compose.yaml puts all six on the suite-data volume at /data; when a variable is unset, the default in the table applies.

Variable Default Holds
SUITE_DB suite.documents.sqlite Loro snapshots and update logs, one room per workspace
SUITE_SHARES_DB suite.shares.sqlite share rows, token hashes, draft baselines, projection state
SUITE_WORKSPACES_DB suite.workspaces.sqlite owner-key hashes and workspace rows
SUITE_SETTINGS_DB :memory: per-workspace runtime quota overrides
SUITE_LICENCE_DB :memory: licence claims, recorded per licence id
SUITE_ATTACHMENTS_DB suite.attachments.sqlite attachment bytes and their metadata

A relative path resolves against the working directory, so :memory: and a bare filename are fine for a local run and wrong for a container. SUITE_REQUIRE_DURABLE_STORES=1 (set by compose.yaml) requires each of the six to be an absolute file path in one directory, and refuses :memory:, a relative path or two stores in one file. Two stores must never share a file, whatever the mode.

Variable Default Effect
SUITE_HOSTED 0 1 enforces hosted licences and installs the hosted quota defaults; SUITE_LICENCE_PUBLIC_KEY becomes required and SUITE_LICENCE is refused. It also opens the relay’s licence claim store, so the panel can write the four plan states and pnpm drill:billing has something to walk; a self-host answers 501 to that route
SUITE_LICENCE none signed licence token for a self-hosted instance, verified at boot; see Self-host licence
SUITE_LICENCE_PUBLIC_KEY none Ed25519 public key that verifies SUITE_LICENCE and the relay’s licence tokens, as PEM or the base64url raw key
SUITE_RELAY_PURGE_TOKEN none bearer the control plane presents to the relay’s own workspace delete, so an account erase removes documents, shares and attachments. It opens that one route and nothing else
SUITE_RELAY_URL none where the control plane reaches that relay. Empty leaves the three stores in place and reports them as not-configured
SUITE_ADMIN_EMAILS none comma-separated addresses that may use the operator surface: /api/admin/*, /metrics and /admin. Matched case-insensitively against the signed-in account, so revoking an operator is a restart rather than a rotation. Unset means nobody, which is the safe default
SUITE_METRICS_LOCAL 0 1 lets a request from a loopback address read /metrics with no credential, which is what a cron scrape on the box needs. It opens nothing else
SUITE_PROJECTION_TOKEN random per start the projection worker’s credential; writes only share- rooms
SUITE_SOURCE_TOKEN random per start the source worker’s credential; writes only a workspace with an active editor share
SUITE_INTERNAL_TOKEN none legacy alias for SUITE_PROJECTION_TOKEN, still accepted
SUITE_VERSION package.json the stamp reported by /metrics, /api/admin/status and the startup line; an empty value is refused
SUITE_LOG_LEVEL info debug, info, warn or error
SUITE_TRUST_PROXY 0 1 uses the first X-Forwarded-For hop as the client address for rate limits
SUITE_ALLOWED_ORIGINS empty browser origins for the HTTP API, comma-separated; empty answers loopback origins only, * answers any origin
SUITE_PUBLIC_URL none base URL used for links created by POST /api/admin/fixtures

An unset worker token is generated per start, which only matters if a worker reconnects after a restart. Neither worker token may reach a browser or a log.

A self-hosted relay starts with no control plane, so nothing mints it a session token and no organization subscription holds its capacity. SUITE_LICENCE is the whole entitlement: one signed token, the line pnpm --filter @suite/collab-server licence sign prints, plus SUITE_LICENCE_PUBLIC_KEY holding the public half of the key that signed it.

The relay verifies the token at boot. A token that is malformed, expired, signed by another key, or of a format this build does not read stops the process, and the error names the case: licence-malformed, licence-expired, licence-bad-signature, licence-unknown-version. A token with no key configured is reported on SUITE_LICENCE_PUBLIC_KEY, and an empty value means no licence.

The claims become the instance caps. workspaces caps SUITE_MAX_WORKSPACES, documentBytes caps SUITE_DOCUMENT_BYTES_PER_WORKSPACE and attachmentBytes caps SUITE_ATTACHMENT_BYTES_PER_WORKSPACE; a lower value set in the environment still wins. The boot writes each cap into that variable before the quota layer reads it, so GET /api/admin/limits reports the cap with the source env. Changing the licence or its caps takes effect at the next start.

SUITE_LICENCE beside SUITE_HOSTED=1 is a contradiction, and the process refuses to start. A hosted relay takes capacity from the organization’s subscription and reads it from the session token, so there is no merge to define. A session token on a self-hosted instance, which is possible when the operator runs a control plane as well, authenticates a person and adds no capacity: the per-workspace claim record is written only in hosted mode. billing.md has the seller’s command and the operator’s side.

Variable Self-host Hosted (SUITE_HOSTED=1)
SUITE_MAX_WORKSPACES unlimited unlimited
SUITE_DOCUMENT_BYTES_PER_WORKSPACE unlimited unlimited
SUITE_PROJECTION_BYTES_PER_WORKSPACE unlimited 32 MiB
SUITE_ATTACHMENT_BYTES_PER_WORKSPACE unlimited unlimited
SUITE_MAX_SHARES_PER_WORKSPACE 100 100
SUITE_MAX_SELECTION_CARDS 200 200
SUITE_DISK_SOFT_BYTES unlimited 4 GiB
SUITE_DISK_HARD_BYTES unlimited 5 GiB

Bytes, counts or unlimited. A hosted licence carries its own workspaces, documentBytes and attachmentBytes, and the tighter of the licence and the instance value wins. A self-host licence caps the same three and follows the same rule. Setting unlimited by hand overrides the hosted guardrails, which is why leaving the variable unset is the safe choice. The exact numbers and the code that enforces each one are in limits.md.

Per client address, fixed one-minute windows. Empty keeps the default shown.

Variable Default
SUITE_CLAIM_RATE_LIMIT 600
SUITE_ROTATE_RATE_LIMIT 10
SUITE_SHARE_RESOLVE_RATE_LIMIT 300
SUITE_SHARE_CREATE_RATE_LIMIT 30
SUITE_PUBLISH_RATE_LIMIT 20
SUITE_ADMIN_RATE_LIMIT 300

These are inlined by Vite at build time, so they are fixed in the bundle and not read at runtime.

Variable Default Effect
VITE_SUITE_MODE relay standalone drops share resolution, the owner claim, the provider and sharing; any other value keeps the full product
VITE_COLLAB_HTTP_URL /api the HTTP API base the canvas calls
VITE_COLLAB_URL ws(s)://<host>/ws the sync WebSocket URL

Accounts, billing and the compose front end

Section titled “Accounts, billing and the compose front end”

The account service is mounted by apps/control-plane/src/modules/auth.ts at /api/auth/*, so these variables affect that process and not the relay. SUITE_AUTH_ENABLED (default 0) turns it on; off, every account path answers 503 auth-not-configured.

Variable Default Effect
SUITE_AUTH_ENABLED 0 1 or true composes the service and mounts its routes
SUITE_AUTH_SECRET none signing secret for cookies and the JWT key pair, at least 32 characters
SUITE_AUTH_JWKS_URL ${SUITE_AUTH_URL}/api/auth/jwks where the relay fetches the key set. Set it when the control plane is reachable under a different name than the public origin, which is every compose deployment: the token names https://app.suite.run and the relay reaches control-plane:1236
SUITE_AUTH_URL none public origin the session cookie is scoped to, and the JWT’s iss and aud
SUITE_AUTH_DATABASE_URL none Postgres for accounts, organizations and licences; required when the service is on
SUITE_AUTH_MIGRATE on 0, false, no or off skips the boot migration for an operator who applies services/auth/sql out of band
SUITE_AUTH_RP_ID localhost WebAuthn relying-party id; the registrable domain passkeys are bound to
SUITE_AUTH_RP_NAME Suite relying-party name shown by the authenticator, and the TOTP issuer
SUITE_AUTH_MAX_ORGS_PER_USER 1 organizations one account may own
SUITE_GOOGLE_CLIENT_ID none with the secret, registers the Google provider
SUITE_GOOGLE_CLIENT_SECRET none one half of the pair without the other refuses to boot
SUITE_TURNSTILE_SECRET none set it to require invisible Turnstile in front of sign-up; unset means no challenge
SUITE_TURNSTILE_SITE_KEY none the same widget’s public site key, passed to the image build as a VITE_ argument: Vite inlines it, so a runtime-only value never reaches the browser
SUITE_ADMISSION_REQUIRED 0 1 holds a new account until an operator admits it, gating /organization/create. Refused at startup unless SUITE_ADMIN_EMAILS names an operator, because the queue would have no door
SUITE_APP_URL none where the admission notice sends someone; unset admits without sending one
SUITE_MAIL_TRANSPORT console console, resend or smtp. Anything else refuses to boot
SUITE_MAIL_FROM none sending address, e.g. Suite <hello@example.com>; required by resend and smtp
RESEND_API_KEY none required by SUITE_MAIL_TRANSPORT=resend
SUITE_SMTP_URL none required by SUITE_MAIL_TRANSPORT=smtp: smtp://user:pass@host:587 or smtps://host:465
SUITE_SMTP_REQUIRE_TLS 1 0 accepts a relay with no StartTLS; only for a loopback or private network
SUITE_FEEDBACK_TO none where the in-app feedback bubble sends; unset hides the bubble, because a form that cannot send is worse than none
SUITE_AUTH_REQUIRE_VERIFICATION on 0 or false issues a session before the address is confirmed. For a local run with no mailbox; a deployment that sets it has an open sign-up

Sign-up is open, and an account cannot sign in until its address is verified. The service takes a MailTransport and the control plane chooses one from SUITE_MAIL_TRANSPORT: console writes the link to the process log with the token redacted, resend posts to the Resend API, and smtp speaks submission SMTP with StartTLS and AUTH PLAIN or AUTH LOGIN. A vendor transport missing its key or its sender refuses to boot rather than falling back to console, which would look healthy while every verification email went to a log file. The meanings are in security-model.md.

Billing owns SUITE_BILLING_ENABLED, SUITE_BILLING_STORE, SUITE_BILLING_DATABASE_URL, SUITE_APP_ORIGIN, SUITE_LICENCE_PRIVATE_KEY, STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, STRIPE_PRICE_TIERS, SUITE_SELF_HOST_URL, SES_FROM_ADDRESS and SUITE_SES_REGION, all listed in billing.md.

SUITE_BILLING_MODE names which credential set is live: test (the default) reads STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET and STRIPE_PRICE_TIERS, and live reads only the _LIVE versions of those three. A webhook is verified with the active mode’s signing secret and no other, so a test event that arrives while the deployment is live fails verification instead of writing a licence. Switching to live with the _LIVE set incomplete leaves billing off, and the boot log names the variables it wanted.

SUITE_PG_DUMP_URL is not read by a process; it tells deploy/backup.sh and deploy/restore.sh how to reach a managed Postgres. Unset, they dump through the compose postgres service instead, which is the local shape. The postgres service itself sits behind the local-db profile: pnpm up passes it, a deployed host with RDS does not.

| SUITE_OPERATOR_ALLOW | tailnet + loopback | space-separated CIDRs Caddy serves /admin*, /metrics and /api/admin* to. A local stack adds 172.16.0.0/12 for the Docker bridge; a deployed host keeps the tailnet and nothing else |

compose.yaml adds three variables it consumes itself: SUITE_DOMAIN (the hostname Caddy serves), SUITE_ACME_EMAIL (the ACME account address) and SUITE_IMAGE (the image to run, defaulting to the image the host builds locally, suite:local).

The tables above are written by hand from config/config.ts, compose.yaml and .env.example, and pnpm test covers the parser rather than this page. A later change may generate the variable lists from config.ts and fail the docs check when a variable is missing here; until then, the way to verify a variable is to grep config.ts for it.