Skip to content

Feature toggles

Several behaviours are switched from .env. Like all settings, they are read at runtime — change the value and restart the stack to apply it, no rebuild needed:

Terminal window
docker compose up -d
Variable Default Effect
REGISTRATION_ENABLED true Enables self-service sign-ups.

Public sign-ups are on by default. Set REGISTRATION_ENABLED=false to run a private / invite-only instance: /register returns 404 and the “sign up” links are hidden. Existing users and email invitations still work.

Variable Default Effect
TERMS_URL (unset) Link target for “terms” on the register screen.
PRIVACY_URL (unset) Link target for “privacy notice” on the register screen.

The Desk ships no terms of service or privacy notice of its own: a self-hosted instance is governed by whoever runs it. So both are unset by default and the register screen shows no consent row at all.

Set both to add a required “I agree to the terms and the privacy notice” checkbox to registration, linking to your own documents. Registration is then refused server-side without agreement, so the checkbox cannot simply be skipped by posting the form directly.

Variable Default Effect
EMAIL_VERIFICATION_ENABLED false Require new accounts to confirm their email before using the app.

A single deploy-time flag for self-hosters. It defaults to off: registration logs the new user straight in, and every account is treated as verified.

Set EMAIL_VERIFICATION_ENABLED=true to require confirmation. New accounts must click the verification link before they can use the app, so your SMTP settings must work or new users will be stuck.

Variable Default Effect
TWO_FACTOR_AUTH_ENABLED false Let users add TOTP two-factor authentication to their account.

A single deploy-time flag for self-hosters. It defaults to off, keeping password-only sign-in.

Set TWO_FACTOR_AUTH_ENABLED=true to surface two-factor authentication under Settings → Security. Each user can then enrol an authenticator app (a time-based one-time code, or TOTP) and save single-use recovery codes; at the next sign-in they’re challenged for a code. Enrolment is per-user and opt-in — turning the flag on offers the option but never forces anyone to enrol.

The toggle takes effect immediately with no data migration: the underlying routes are always registered, so flipping the flag only changes whether the option is offered.

Variable Default Effect
PASSKEYS_ENABLED false Let users sign in passwordlessly with WebAuthn passkeys.

A single deploy-time flag for self-hosters. It defaults to off, keeping password-only sign-in.

Set PASSKEYS_ENABLED=true to surface passkey management under Settings → Security and a “Sign in with a passkey” button on the login screen. Each user can then register one or more passkeys — Touch ID, Face ID, Windows Hello, or a hardware security key — name them, and remove them (each change is confirmed with their password). At the next sign-in they can authenticate with a passkey instead of a password. Registration is per-user and opt-in — turning the flag on offers the option but never forces anyone to enrol.

With the flag on, a user who has just created an account is offered a passkey once, in a dismissible dialog on their first landing in the workspace. The ceremony runs inline: they confirm on their device and the passkey is saved without ever leaving the page. The name field is prefilled with the device they signed up on (“Chrome on macOS”) and stays editable, because a passkey cannot be renamed afterwards.

Either answer closes the offer for good — “Not now” and a successful enrolment both settle it, so a refresh never re-asks. The first-run tour waits until the prompt is out of the way, and the prompt is skipped entirely on a browser with no WebAuthn support.

Only new registrations are prompted. Turning the flag on for an existing instance deliberately does not nudge everyone already signed up; those users enrol from Settings → Security whenever they choose.

The toggle takes effect immediately with no data migration: the underlying routes are always registered, so flipping the flag only changes whether the option is offered.

Variable Default Effect
AUTH_SSO_ONLY false Route all access through single sign-on.

Single sign-on — OpenID Connect or LDAP / Active Directory — sits alongside password login by default, so a break-glass password account keeps working during an outage. Set AUTH_SSO_ONLY=true to funnel everyone through the directory instead: Fortify registration and the local-password login are disabled. (LDAP bind auth uses the same login form, so it keeps working — only the local-password path is blocked.)

AUTH_SSO_ONLY only takes effect once a provider (OIDC or LDAP) is actually configured, so a stray flag can never lock everyone out of an instance with no working SSO.

Variable Default Effect
SCIM_TOKEN (blank) Mounts a bearer-token SCIM 2.0 endpoint for the IdP to push to.

Setting SCIM_TOKEN turns on the SCIM 2.0 provisioning endpoint at ${APP_URL}/scim/v2, letting your identity provider (Okta, Entra ID, …) create, update, and deactivate accounts automatically as directory membership changes. A deactivation tombstones the account — access is revoked and sessions end, but history is retained — and it can be reversed by a later active: true. Leave SCIM_TOKEN blank and the endpoint is not mounted at all.

See Environment variables → Directory provisioning (SCIM 2.0) for the full setup.

The integrations platform lets external systems act inside a workspace as bot users through a versioned public REST API at ${APP_URL}/api/v1, and lets them subscribe to outgoing webhooks so your systems can react to activity.

Variable Default Effect
INTEGRATIONS_ENABLED true Enables bot users, the public REST API, and outgoing webhooks. Set false to turn the whole surface off — every /api/v1 route returns 404, the management UI hides, and no webhooks are delivered.
INTEGRATIONS_API_RATE_LIMIT 60 Maximum requests per token, per minute. Exceeding it returns 429 with a Retry-After header.

The platform is on by default. A bot authenticates with a hashed Bearer token minted for it, scoped to fine-grained resource:action abilities (for example messages:write, channels:read) — each endpoint enforces exactly the scope it needs, and a token acts only within the channels its bot belongs to. Set INTEGRATIONS_ENABLED=false to disable the feature entirely; the routes then behave as if they do not exist.

Incoming webhooks let an external system post a message into one channel by POSTing to an unguessable URL — ${APP_URL}/webhooks/incoming/{token} — where the opaque token in the URL is the credential (Slack-style). Each webhook is bound to a single bot and channel, its token is stored only as a hash, and it is individually revocable. The JSON body accepts either a native {"body": "..."} or a Slack-compatible {"text": "..."} field (Block Kit is ignored); an optional HMAC X-Signature-256 header is honoured when a signing secret is configured. Incoming webhooks are governed by the same INTEGRATIONS_ENABLED toggle — the endpoint 404s when the platform is off.

The toggle takes effect immediately with no data migration.

A bot with the webhooks:write scope can register subscriptions (via POST /api/v1/webhooks) that deliver a signed POST to an external URL whenever a subscribed event happens. See the outgoing webhooks reference for the event set, payload shapes, and signature verification. Delivery is tunable:

Variable Default Effect
WEBHOOKS_MAX_ATTEMPTS 5 Attempts per event before giving up, each retried with exponential backoff.
WEBHOOKS_TIMEOUT 5 Seconds each delivery request may run before it counts as a failed attempt.
WEBHOOKS_DISABLE_AFTER 5 Consecutive failed deliveries (with no success in between) after which a subscription is auto-disabled and stops delivering.
WEBHOOKS_DELIVERY_RETENTION_DAYS 30 How many days of delivery attempts to keep. Each attempt stores the payload it sent, which is what makes a replay possible — and means the log holds a copy of workspace data until a daily job prunes it. Set to 0 to keep every attempt forever.
WEBHOOKS_BLOCK_PRIVATE_URLS true SSRF guard. Rejects webhook URLs that aren’t public http/https addresses — loopback, private, link-local, and cloud-metadata (169.254.169.254) targets, plus localhost/.local/.internal hostnames — both when a subscription is registered and again before every delivery. Before connecting, delivery also resolves the hostname, blocks it if any resolved address is non-public, and pins the connection to the vetted IP (closing DNS-rebinding attacks); deliveries never follow HTTP redirects. Turn off only for a locked-down instance that deliberately targets internal endpoints.

Webhooks ride on the same INTEGRATIONS_ENABLED master switch — turning the platform off stops all delivery immediately, including jobs already queued.

User avatars are derived from Gravatar using the MD5 hash of each user’s email address (so the raw address never appears in the URL). A user without a Gravatar falls back cleanly to their initials.

Variable Default Effect
GRAVATAR_ENABLED true Derive avatars from Gravatar. Set false for initials only and no outbound requests to gravatar.com.
GRAVATAR_URL https://www.gravatar.com/avatar The Gravatar endpoint. Point it at a mirror to avoid gravatar.com.
GRAVATAR_SIZE 200 Requested square image size, in pixels.
GRAVATAR_DEFAULT 404 The d= fallback. 404 is what makes users without a Gravatar fall back to initials; mp, identicon, or a URL are alternatives.

The Desk records an activity log (audit trail) of notable actions.

Variable Default Effect
ACTIVITYLOG_ENABLED true Records activity to the database. Set false to disable logging entirely.
ACTIVITYLOG_BUFFER_ENABLED false Buffers log writes and flushes them in a batch (advanced; reduces write volume).

Every web response carries a Content-Security-Policy header — the browser-side allow-list that limits what injected markup could do. It is on by default and needs no proxy configuration. See Security & compliance → Content Security Policy for the policy itself and the two accepted residuals.

Variable Default Effect
CSP_ENABLED true Sends the policy. Set false only to serve your own from the reverse proxy.
CSP_REPORT_ONLY false Sends it as Content-Security-Policy-Report-Only: violations are logged to the browser console but nothing is blocked.

Report-only is the safe way to try a change: turn it on, browse the app with the developer console open, fix whatever is reported, then turn it back off. Leaving it on permanently protects nobody.

If you add a script, stylesheet, image host, API, embedded frame or font provider of your own, name it in the matching key rather than disabling the policy. Values are comma-separated and appended to the defaults — they can never remove the script nonce or 'strict-dynamic', so an allow-list entry cannot silently un-harden the app.

Variable Adds to
CSP_EXTRA_SCRIPT_SRC script-src
CSP_EXTRA_STYLE_SRC style-src
CSP_EXTRA_IMG_SRC img-src
CSP_EXTRA_CONNECT_SRC connect-src
CSP_EXTRA_FRAME_SRC frame-src
CSP_EXTRA_FONT_SRC font-src
Terminal window
CSP_EXTRA_SCRIPT_SRC="https://analytics.example.com"
CSP_EXTRA_CONNECT_SRC="https://analytics.example.com"

An external font is governed by two directives, because a stylesheet and the font files it references are different resource types: the host serving the CSS goes in CSP_EXTRA_STYLE_SRC, and the host serving the @font-face files goes in CSP_EXTRA_FONT_SRC. Google Fonts splits those across two hosts, so it needs both:

Terminal window
CSP_EXTRA_STYLE_SRC="https://fonts.googleapis.com"
CSP_EXTRA_FONT_SRC="https://fonts.gstatic.com"

Setting only CSP_EXTRA_STYLE_SRC there lets the stylesheet load and then blocks every @font-face file it asks for, so the text still falls back — the half-configured case is the one that looks mysterious. A provider that serves both from a single origin needs that origin in both keys; a font referenced from your own CSS needs only CSP_EXTRA_FONT_SRC. The app self-hosts its own fonts, so reach for this only if you deliberately add a web font of your own — see Security → Content Security Policy.

There is deliberately no key that replaces the whole policy: an override that could drop the nonce would leave a header that looks protective and is not.

Nothing may embed the app in a frame by default. That closes clickjacking: an attacker loads your instance in an invisible iframe over their own page, and a signed-in member who thinks they are clicking that page is really clicking your controls — leaving a workspace, deleting a channel, revoking a token.

Variable Default Effect
CSP_FRAME_ANCESTORS none Who may frame the app. Always sets the CSP frame-ancestors directive; also sends X-Frame-Options when the value maps to DENY or SAMEORIGIN and the policy is enforcing.

Accepted values:

Value frame-ancestors X-Frame-Options
none (default) 'none' — nobody DENY
self 'self' — your own origin SAMEORIGIN
One or more origins those origins (not sent)
Terminal window
# Embed the app in your intranet portal
CSP_FRAME_ANCESTORS="https://portal.example.com"

Both headers ride on CSP_ENABLED. Turning the app policy off means you have taken ownership of these headers at your reverse proxy, so set them there too. Under CSP_REPORT_ONLY=true the directive is reported but not enforced, and X-Frame-Options is withheld — it has no report-only form, so sending it would enforce the very thing the dry run is meant to only observe.

Responses that arrive over HTTPS carry Strict-Transport-Security, which tells the browser to reach the host over HTTPS only from then on. Without it the first visit, or any later one typed without a scheme, still goes out as plain HTTP — the window an on-path attacker uses to strip TLS and read the session cookie before your redirect to HTTPS ever happens.

Variable Default Effect
HSTS_ENABLED true Send the header at all. Turn off only if your reverse proxy sends it.
HSTS_MAX_AGE 31536000 Seconds the browser remembers the pin (one year). 0 forgets the host.
HSTS_INCLUDE_SUBDOMAINS true Extend the pin to every subdomain.
HSTS_PRELOAD false Add preload. See the warning below.

The defaults send:

Strict-Transport-Security: max-age=31536000; includeSubDomains

The header is never sent on a request that arrived over plain HTTP, so a LAN deployment served over http:// cannot lock itself out of its own hostname. TLS is detected from your proxy’s X-Forwarded-Proto, which the app already trusts — see Reverse proxy & TLS.

preload is only sent when the rest of the policy would actually qualify for the list — HSTS_MAX_AGE of at least 31536000 and HSTS_INCLUDE_SUBDOMAINS=true. Set it beside a shorter max-age or with subdomains excluded and the directive is left off rather than advertising an intent the policy cannot back.

Turn HSTS_INCLUDE_SUBDOMAINS off if a subdomain of your app’s host still has to answer over plain HTTP — the pin would otherwise make it unreachable too.

Variable Default Effect
MEILISEARCH_NO_ANALYTICS true Disables Meilisearch’s anonymous usage analytics.

The production stack sets this to true by default (MEILI_NO_ANALYTICS). Set it to false only if you deliberately want to send Meilisearch usage analytics.

These are off by default and only relevant for larger or multi-node deployments. Most single-host instances leave them alone.

Variable Default Effect
REVERB_SCALING_ENABLED false Enables horizontal scaling of Reverb across multiple servers (Redis pub/sub).
REVERB_APP_RATE_LIMITING_ENABLED false Enables per-connection message rate limiting.
REVERB_APP_RATE_LIMIT_TERMINATE false When rate limiting is on, disconnects clients that exceed the limit rather than just throttling.
Variable Default Effect
UPDATE_CHECK_ENABLED true Check daily whether a newer stable release is available.
UPDATE_CHECK_REPOSITORY deskhq/the-desk The GitHub owner/repo to check and link release notes to.
UPDATE_CHECK_CACHE_TTL_HOURS 12 How long a successful check is trusted before the next one.

Update checks are on by default. Once a day a scheduled command asks the GitHub Releases API for the latest stable release (drafts and pre-releases are ignored) and caches the result. When your instance is behind, every signed-in user sees a low-key “update available” strip in the sidebar, and the running version appears in Settings → About this instance and the user menu. The strip is dismissible per version — it comes back on the next release.

The check fails silently: no network, an air-gapped host, a rate limit, or a GitHub outage never blocks a request or shows an error — the last known-good result is kept.

Set UPDATE_CHECK_ENABLED=false for an air-gapped instance: no outbound update-check request is ever made, and the UI shows only the running version with no “update available” claim. Forks can point UPDATE_CHECK_REPOSITORY at their own upstream.

Variable Default Effect
GIPHY_API_KEY (blank) Enables the composer’s /gif Giphy picker.

The /gif picker is off by default. Setting GIPHY_API_KEY (a free key from developers.giphy.com) turns it on: typing /gif in the composer opens a picker that searches Giphy — trending on an empty query, debounced search as you type, infinite scroll — and the chosen GIF is sent as a normal message attachment.

Leave the key blank and the feature is fully hidden: the /gif command is absent from autocomplete, the picker never appears, and the search/attach endpoints return 404. The key is read server-side and never exposed to the browser.

GIPHY_CONTENT_RATING (default g) caps the strictest rating Giphy may return — g, pg, pg-13, or r. g keeps results workplace-safe; loosen it for a casual community. See Environment variables → GIFs (Giphy).

Variable Default Effect
POLLS_ENABLED true Enables the /poll builder.

Polls are on by default. Typing /poll in the composer opens a builder — a question, two to ten options, and toggles for multiple answers and an anonymous poll — and posts the poll as a first-class message. Members vote inline (single- or multiple-choice), tallies update live, and the creator or a team admin can close a poll to freeze its results.

Set POLLS_ENABLED=false to turn the feature fully off: the /poll command is absent from autocomplete, the builder never appears, and the create, vote, and close endpoints return 404. Existing poll messages render their last-known tally read-only. See Environment variables → Feature toggles.

Variable Default Effect
STORAGE_QUOTA_MB 0 Caps the upload storage each workspace may occupy, in megabytes.

Storage is unlimited by default: 0 (or leaving the variable unset) turns the feature off entirely, and nothing is measured. Set a value in megabytes to cap how far a workspace can grow your disk — STORAGE_QUOTA_MB=5000 gives every workspace 5 GB.

The cap applies uniformly to every workspace, personal ones included: it is one instance-wide value, not a per-team setting. Enforcement is a hard block checked before the file is written, so an upload that would cross the quota is refused with an error in the composer and leaves nothing on disk.

What counts toward the quota is exactly what sits on your disk:

  • Uploaded files count, whether they are already sent or still staged in a composer — a pending upload occupies disk just like a sent one.
  • Deleted messages keep counting until their attachments are permanently removed. A deleted message’s files are retained (they are recoverable), so the bytes are still yours; they are released once the row is purged for good.
  • GIFs from the Giphy picker do not count. They are hotlinked from Giphy’s CDN and store no file of yours.

Admins and the workspace owner see the workspace’s usage — used, quota, and percentage — on the workspace Analytics dashboard. With no quota configured the read-out is absent, since there is nothing to measure against.

Variable Default Effect
BRANDING_ATTRIBUTION true Shows the attribution line in the footer.

A small Powered by The Desk attribution, linking to the project. It is on by default. The attribution appears on the welcome page, in the user menu, and at the foot of the mobile settings index.

Set BRANDING_ATTRIBUTION=false and it disappears from all three. Nothing else changes.

Replacing the name, mark and icons is a separate, larger job: see Branding.

Variable Default Effect
DEMO_MODE false Turn the instance into a public, single-shared-account demo.

Demo mode is for running a public playground off the seeded “Northwind Labs” workspace (see the demo:seed command), where every visitor signs in as the same account and lands as the workspace owner. Because that one account is shared, the mode adds guard rails so no visitor can lock out, evict, or deface the workspace for everyone else. It defaults to off, and when off it changes nothing — leave it off on any real deployment.

Set DEMO_MODE=true to enable all of the following at once:

  • A one-click way in. An “Enter the demo” button appears on the welcome and login screens and signs the visitor straight into the shared account, so nobody has to know or type the seeded credentials. The button is absent off the demo, and its endpoint returns 404 there too.
  • Destructive owner actions are blocked. Changing the shared account’s email, password, or name; enabling two-factor or a passkey; revoking sessions; deleting the account or team; renaming the team or editing its slug; transferring ownership; and removing or leaving members are all rejected server-side and their UI controls render disabled with a “Disabled in the demo” tooltip.
  • All outbound email is swallowed. The mail transport is forced to the in-memory array driver, so invites, password resets, verification, and notifications never leave the host — regardless of your SMTP settings.
  • Writes are rate-limited per IP. Demo entry (~10/min), message sends (~30/min), and attachment uploads (~10/min) are throttled by IP address (per-user throttling is useless when everyone shares one account). The caps are generous enough that honest exploring never trips them.
  • Self-registration is forced off. /register returns 404 regardless of REGISTRATION_ENABLED, so a visitor can’t create a fresh account with its own unguarded personal team and sidestep the rails.
  • The workspace heals hourly. A scheduled demo:seed runs every hour to wipe and rebuild “Northwind Labs”, undoing whatever visitors changed. Make sure the scheduler is running.
Variable Default Effect
VAPID_PUBLIC_KEY / VAPID_PRIVATE_KEY (unset; filled on a fresh install) Enables browser push notifications for new messages.

Web push needs a VAPID keypair. A fresh install gets one from ./docker/gen-secrets.sh, which fills both keys with the rest of your secrets. An install that predates that — or a platform-managed deployment, where the script has nothing to write into — generates its own pair with the webpush:vapid --show command; see Environment variables → Web push notifications. With no keypair the toggle never appears in Settings, and the subscription endpoints return 404.

With the keys set, the feature is still opt-in per member and per device: nothing prompts on load or on login. Each member turns it on from Settings → Appearance & notifications → Push notifications on this device, which is what asks the browser for permission. Turning it on on a laptop does not subscribe their phone, and either can be turned off on its own.

What gets pushed follows the same rules as the in-app chime, so a push never contradicts a badge:

  • Direct messages and channels at the default All messages level push on every message; a channel set to Mentions only pushes only when the member is @mentioned; Nothing and muted channels never push.
  • A member is never pushed about their own message, and quiet hours or a manual do-not-disturb pause suppress everything.
  • Notifications are collapsed per conversation: a second message in the same channel replaces the banner already on screen rather than stacking up.
  • If a window of the app is visible when the message lands, the banner is dropped — that tab has already chimed and badged.

Clicking a notification focuses an open window (or opens one) directly on the message, scrolled to and highlighted.