Skip to content

Environment variables

Every setting is read from .env at runtime. This reference covers the variables that matter when self-hosting. On/off feature switches have their own page: Feature toggles.

One variable in .env is read by the docker compose CLI itself rather than by the app:

Variable Production template value Notes
COMPOSE_FILE docker-compose.prod.yml Which compose file(s) a bare docker compose resolves. This is the value .env.prod.example ships, not a Compose default: with it unset, a bare docker compose picks up compose.yaml, the development stack. Setting it is why no command on this site passes -f docker-compose.prod.yml. Building from source? List both files separated by a colon: docker-compose.prod.yml:docker-compose.build.yml.

See the COMPOSE_FILE variable for the caveat about a bare docker compose down, and for what to do if your .env predates this variable.

The stack refuses to start without these (no defaults):

Variable Notes
APP_KEY A base64:-encoded 32-byte key. Generated for you.
DB_PASSWORD PostgreSQL password. Generated for you.
MEILISEARCH_KEY Meilisearch master key. Generated for you.
Variable Default Notes
APP_URL Public URL of your instance. Set this.
APP_NAME The Desk Shown in the UI, in emails, and as the installed app’s name. Read at runtime, so renaming the instance needs no rebuild. See Branding.
APP_PORT 8000 Host port the web app is published on (bound to APP_BIND).
APP_BIND 127.0.0.1 Address the published app/reverb ports bind to. 0.0.0.0 exposes the raw HTTP origin off-box.
APP_VERSION (required) The release to run. The compose file pins the image to ghcr.io/deskhq/the-desk:$APP_VERSION, so upgrading is an APP_VERSION bump plus docker compose pull && docker compose up -d — no git checkout. It has no default: up -d fails fast with a clear message if it is unset.
APP_IMAGE (uses APP_VERSION) Full image override. Set it to run a tag on another registry (a fork, an air-gapped mirror) or a floating tag like edge. When set it wins completely and APP_VERSION is ignored.
Variable Default Notes
BRANDING_ATTRIBUTION true Renders the “Powered by The Desk” line in the footer. Set false to remove it.
BRANDING_PATH /app/storage/branding Where the app looks for replacement brand assets. The compose file bind-mounts ./branding onto it, so you only change this if you mount them elsewhere.

Full guide, including the filenames and sizes each asset expects: Branding.

Variable Default Notes
DB_DATABASE laravel PostgreSQL database name.
DB_USERNAME laravel PostgreSQL user.
DB_PASSWORD Required secret (see above).

Background work — real-time broadcasts, mail, link previews, webhook delivery, exports — runs through Redis on the queue and queue-broadcasts services (see Architecture). Neither needs configuring; the one tunable is how a worker waits for work.

Variable Default Notes
REDIS_QUEUE_BLOCK_FOR 1 Seconds a worker holds a blocking read on Redis open before it looks again. A job on the worker’s first queue starts the instant it is dispatched however high this is, so raising it never slows real-time updates. What it does set is how long a job on a secondary queue can sit before the worker rechecks it — on the shared queue worker, that is default, so raising this to 10 can leave mail or a link preview waiting up to ten seconds to start. Values below 1 are floored to 1: the underlying Redis command reads 0 as “wait forever”, which would strand everything but broadcasts.
Variable Notes
MAIL_MAILER smtp for a real mail server.
MAIL_HOST SMTP host.
MAIL_PORT SMTP port (commonly 587).
MAIL_USERNAME SMTP username.
MAIL_PASSWORD SMTP password.
MAIL_FROM_ADDRESS From address on outgoing mail.
MAIL_FROM_NAME From name (defaults to ${APP_NAME}).
Variable Default Notes
MEILISEARCH_KEY Required secret (master key).
MEILISEARCH_VERSION v1.49 Pins the image tag and the version-scoped data volume. See Upgrading.
MEILISEARCH_NO_ANALYTICS true Disable Meilisearch usage analytics.

How the containers reach Reverb. Defaults are correct for the bundled stack.

Variable Default Notes
REVERB_APP_ID Reverb app id. Generated for you.
REVERB_APP_KEY Reverb app key. Generated for you.
REVERB_APP_SECRET Reverb app secret. Generated for you.
REVERB_HOST reverb Internal service host.
REVERB_PORT 8080 Internal (and published) Reverb port.
REVERB_SCHEME http The container speaks plain HTTP.

How the browser reaches Reverb through your TLS proxy. Set these for production — see Configuration.

Variable Set to Notes
REVERB_SCHEME_PUBLIC https Browser connects over TLS.
REVERB_PORT_PUBLIC 443 Your proxy terminates wss on 443.
REVERB_HOST_PUBLIC (APP_URL host) Only set for a dedicated WebSocket subdomain.
REVERB_ALLOWED_ORIGINS * Comma-separated origins allowed to open a WebSocket connection. Lock to the app host in production (e.g. chat.example.com).
Variable Default See
REGISTRATION_ENABLED true Feature toggles → Open registration
TERMS_URL (unset) Feature toggles → Terms & privacy consent
PRIVACY_URL (unset) Feature toggles → Terms & privacy consent
EMAIL_VERIFICATION_ENABLED false Feature toggles → Email verification
TWO_FACTOR_AUTH_ENABLED false Feature toggles → Two-factor authentication
PASSKEYS_ENABLED false Feature toggles → Passkeys
GRAVATAR_ENABLED true Feature toggles → Gravatar avatars
ACTIVITYLOG_ENABLED true Feature toggles → Activity logging
REVERB_SCALING_ENABLED false Feature toggles → Advanced Reverb
AUTH_SSO_ONLY false Feature toggles → SSO-only mode
UPDATE_CHECK_ENABLED true Feature toggles → Update checks
POLLS_ENABLED true Feature toggles → Polls
INTEGRATIONS_ENABLED true Feature toggles → Integrations platform
INTEGRATIONS_API_RATE_LIMIT 60 Feature toggles → Integrations platform
WEBHOOKS_MAX_ATTEMPTS 5 Feature toggles → Integrations platform
WEBHOOKS_TIMEOUT 5 Feature toggles → Integrations platform
WEBHOOKS_DISABLE_AFTER 5 Feature toggles → Integrations platform
WEBHOOKS_BLOCK_PRIVATE_URLS true Feature toggles → Integrations platform
WEBHOOKS_DELIVERY_RETENTION_DAYS 30 Feature toggles → Integrations platform
DEMO_MODE false Feature toggles → Demo mode
CSP_ENABLED true Feature toggles → Content Security Policy
CSP_REPORT_ONLY false Feature toggles → Content Security Policy
CSP_FRAME_ANCESTORS none Feature toggles → Clickjacking protection
HSTS_ENABLED true Feature toggles → HTTPS enforcement (HSTS)
HSTS_MAX_AGE 31536000 Feature toggles → HTTPS enforcement (HSTS)
HSTS_INCLUDE_SUBDOMAINS true Feature toggles → HTTPS enforcement (HSTS)
HSTS_PRELOAD false Feature toggles → HTTPS enforcement (HSTS)

Comma-separated origins appended to the shipped policy, for a script, stylesheet, image host, API, embedded frame or font provider of your own. They are additive only: none of them can remove the script nonce or 'strict-dynamic'. See Feature toggles → Content Security Policy.

Variable Default Adds to
CSP_EXTRA_SCRIPT_SRC (none) script-src
CSP_EXTRA_STYLE_SRC (none) style-src
CSP_EXTRA_IMG_SRC (none) img-src
CSP_EXTRA_CONNECT_SRC (none) connect-src
CSP_EXTRA_FRAME_SRC (none) frame-src
CSP_EXTRA_FONT_SRC (none) font-src

A stylesheet origin belongs in CSP_EXTRA_STYLE_SRC and a font-file origin in CSP_EXTRA_FONT_SRC, independently. Google Fonts serves the two from different hosts, so it needs both:

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

There, one without the other still fails. A provider serving both from one origin needs that origin in both keys, and a font referenced from your own CSS needs only CSP_EXTRA_FONT_SRC. The app self-hosts its own fonts, so you only need this if you deliberately add a web font of your own.

One more key controls who may embed the app in a frame, rather than what the app may load:

Variable Default Effect
CSP_FRAME_ANCESTORS none Sends frame-ancestors and X-Frame-Options. See Feature toggles → Clickjacking protection.
Variable Default Notes
SESSION_SECURE_COOKIE (derived from APP_URL) Adds the Secure flag, so the browser never sends the session cookie over plain HTTP. Defaults to true when APP_URL starts with https://, false otherwise.
SESSION_LIFETIME 480 Minutes of inactivity before a session expires. Eight hours, so a workspace left alone across a working day is still signed in on return. Signing in with “Keep me signed in” outlives this.
SESSION_ENCRYPT false Encrypt session payloads at rest in Redis.
SESSION_DOMAIN null Cookie domain. Leave unset unless you deliberately share the cookie across subdomains.

You only need to set SESSION_SECURE_COOKIE explicitly if the scheme in APP_URL does not describe how browsers actually reach the app — for example when TLS is terminated at a hostname other than the one APP_URL names. Setting it to true on a deployment served over plain HTTP means the browser will never return the cookie and nobody can stay signed in.

How long the app keeps the records it writes about people. Enforced by the scheduler, so these windows only apply on a deployment where the scheduler container is running.

Variable Default Notes
SECURITY_EVENT_RETENTION_DAYS 365 Days a per-user security event (sign-in, credential change, session revocation, data-export activity) is kept before a daily sweep deletes it. One year covers an annual assessment period. Set 0 to keep events forever.
AUDIT_LOG_RETENTION_DAYS 365 Days a workspace audit-log entry (rename, role change, member removal, ownership transfer, channel lifecycle, message deletion, invitation lifecycle) is kept before a daily sweep deletes it. Matches the window above so both logs answer the retention question with the same number. Set 0 to keep entries forever.

Two more windows are set elsewhere, and are listed here so the whole picture is in one place:

  • Data-export archives and audit-evidence exports are downloadable for a fixed 7 days — not configurable — after which a daily sweep deletes both the file and its record.
  • Uploaded-but-never-sent attachments are swept after ATTACHMENT_PENDING_TTL_HOURS (see Attachments).

Setting either retention variable to 0 turns its sweep off and keeps that log forever, which makes disposal your responsibility. See Security & compliance for what that means for an assessment.

Let members authenticate through your identity provider (Okta, Microsoft Entra ID, Google Workspace, Auth0, Keycloak, …). The app reads the provider’s discovery document at {issuer}/.well-known/openid-configuration to find its endpoints, so only the issuer, client id, and secret are required. The first SSO login just-in-time provisions the account — matched to an existing user by verified email, otherwise created — into the default team as a Member. Leave SSO_OIDC_CLIENT_ID / SSO_OIDC_ISSUER blank to keep SSO off (no button shown).

Variable Default Notes
SSO_OIDC_ISSUER (blank) Your provider’s issuer URL. Discovery is read from {issuer}/.well-known/openid-configuration.
SSO_OIDC_CLIENT_ID (blank) The OAuth client id registered at your provider.
SSO_OIDC_CLIENT_SECRET (blank) The client secret.
SSO_OIDC_REDIRECT_URI ${APP_URL}/auth/oidc/callback Callback URI; must match what you register at the IdP.
SSO_OIDC_DISCOVERY_URL (derived from issuer) Override only if discovery is not at the standard well-known path.
SSO_OIDC_SCOPES openid profile email Space-separated OIDC scopes to request.
SSO_OIDC_VALIDATE_ID_TOKEN true When the provider returns an id_token, verify its signature (via the provider JWKS), issuer, audience, and expiry, and require its subject to match the UserInfo subject. Defence-in-depth; set false only for a non-conformant provider whose id_token cannot be validated.
SSO_OIDC_REQUIRE_VERIFIED_EMAIL false A login whose UserInfo reports email_verified: false is always rejected — never linked to an existing account nor provisioned. Set true to also reject logins whose UserInfo omits the claim entirely (fail-closed). Off by default because many conformant IdPs never send the claim.
SSO_DEFAULT_TEAM_ID (sole team) Team new SSO users join as a Member. Blank uses the sole team when there is exactly one; otherwise the account gets its own workspace. Shared with LDAP.
AUTH_SSO_ONLY false Route all access through SSO (OIDC or LDAP). See SSO-only mode.

Authenticate members against an on-prem LDAP / Active Directory directory. Unlike OIDC’s browser redirect, users enter their directory credentials in the app’s own login form and the app binds to the directory to verify them. On a successful bind the entry is matched to an app user by its mail attribute, keyed by its stable objectGUID, and — like OIDC — just-in-time provisioned into the default team as a Member with a verified email. The mapped display name is synced on every login. Leave LDAP_HOST / LDAP_BASE_DN blank to keep LDAP off.

Directory login sits alongside the local password form by default, so a break-glass password account survives a directory outage. AUTH_SSO_ONLY=true engages once LDAP is configured too, disabling the local-password path while still allowing the directory bind.

Variable Default Notes
LDAP_HOST (blank) Directory host. Blank (with LDAP_BASE_DN) keeps LDAP off.
LDAP_PORT 389 389 for plain/STARTTLS, 636 for LDAPS.
LDAP_BASE_DN (blank) Base DN searched for the user, e.g. dc=example,dc=com.
LDAP_USERNAME (blank) DN of the service (bind) account used to search before binding as the user.
LDAP_PASSWORD (blank) Password for the service account.
LDAP_TLS false Use LDAPS (encrypted transport, usually port 636).
LDAP_STARTTLS false Upgrade a plain connection with STARTTLS.
LDAP_TIMEOUT 5 Connection timeout in seconds.
LDAP_ATTR_USERNAME mail Directory attribute matched against the login form value. Set to e.g. samaccountname to sign in with a directory username instead of email.
LDAP_ATTR_MAIL mail Directory attribute used as the app email (how users are matched/linked).
LDAP_ATTR_NAME cn Directory attribute synced to the app display name on every login.
LDAP_ATTR_GUID objectguid Stable identity attribute. objectguid for Active Directory, entryuuid for OpenLDAP.
LDAP_CONNECTION default Name of the connection in config/ldap.php to use.

Let your identity provider (Okta, Entra ID, OneLogin, …) push user lifecycle changes over SCIM 2.0, so removing someone from the directory automatically deactivates their account here. This is separate from the login paths above: it is a bearer-token REST API the IdP calls, not a form users sign in through.

Point your IdP at ${APP_URL}/scim/v2 and authenticate it with SCIM_TOKEN. Creates match or just-in-time provision the user through the same rules as OIDC/LDAP (email match or create, default team as Member). Deactivations (active: false or DELETE) tombstone the account — access is revoked and every session ends, but history is kept, not hard-deleted — and a later active: true reactivates it. Leave SCIM_TOKEN blank to keep the endpoint off (it is not mounted at all without a token).

Variable Default Notes
SCIM_TOKEN (blank) Bearer token the IdP presents on every SCIM request. Blank keeps the endpoint off. Use a long random secret.
SCIM_BASE_PATH /scim Route prefix the SCIM API mounts under; the versioned resources sit beneath it. With the default, the IdP base URL is ${APP_URL}/scim/v2 and users live at /scim/v2/Users.

Files and images members attach to messages.

Variable Default Notes
ATTACHMENT_MAX_SIZE_MB 25 Largest single file a member can upload, in megabytes.
ATTACHMENT_MAX_PER_MESSAGE 10 Most files that can ride a single message.
ATTACHMENT_PENDING_TTL_HOURS 24 How long an uploaded-but-never-sent file is kept before it is swept.
ATTACHMENT_DISK local Private disk files are stored on. Point at a configured S3 disk for bucket storage.
ATTACHMENT_IMAGE_DRIVER imagick Image library used to strip EXIF metadata and build thumbnails: imagick or gd.
ATTACHMENT_THUMBNAIL_MAX_PX 720 Longest edge, in pixels, of a generated image thumbnail. Images are only scaled down.
STORAGE_QUOTA_MB 0 Total upload storage each workspace may occupy, in megabytes. 0 means unlimited.

The composer’s /gif picker searches Giphy and sends the chosen GIF into any channel or DM. It is off until you supply an API key, so a default deployment ships without it — the /gif command and picker are hidden, and the search/attach routes (though still registered) return 404.

Variable Default Notes
GIPHY_API_KEY (blank) A Giphy API key (free from developers.giphy.com). Blank hides the feature entirely.
GIPHY_CONTENT_RATING g Strictest rating Giphy may return: g, pg, pg-13, or r. g is workplace-safe.

See Feature toggles → GIF picker for how the feature behaves once enabled.

The Security settings page can show an approximate location (city, country) next to each active session, derived from its IP address. The lookup is fully offline against a local MaxMind GeoLite2 / GeoIP2 City database — no third-party API is called — and is opt-in: without a database file the location segment is simply omitted, and it is always omitted for private, LAN, or otherwise unresolvable addresses.

Variable Default Notes
GEOIP_DATABASE_PATH storage/app/geoip/GeoLite2-City.mmdb Absolute path to a GeoLite2-City.mmdb (or GeoIP2 City) database. Unset, the app resolves the shown default under storage_path(). A missing file → no locations shown.

To enable it, download a free GeoLite2 City database from MaxMind (a free account is required) and mount the .mmdb file at GEOIP_DATABASE_PATH. The database is not bundled: MaxMind’s licence does not allow redistribution, and it is refreshed regularly, so you keep it up to date yourself.

Every member sees a small dot beside their teammates’ avatars: filled when someone is active, a hollow ring when they are away, and none at all when they are not connected. Away has two sources — a member can set it by hand from the user menu (which persists until they unset it), and each browser tab reports itself idle after a stretch with no pointer, keyboard, scroll, or focus activity. Someone counts as away only once every device they are signed in on has gone idle, so a laptop in use keeps them active however long a phone has been asleep.

Variable Default Notes
PRESENCE_AWAY_AFTER_MINUTES 10 Minutes a tab may go without activity before it reports itself idle. Floored at 1; there is no “never”.

Idle detection runs in the browser, so the threshold is served to each client with the page rather than baked into the build — changing it takes effect on every client’s next page load, with no rebuild.

Members can opt in, per device, to browser notifications for new messages — so a mention still reaches them with the tab closed. The feature needs a VAPID keypair, which signs every push; see Feature toggles → Web push notifications for what it does and how it behaves.

Variable Default Notes
VAPID_PUBLIC_KEY (unset) Public half of the signing keypair. Served to the browser so it can subscribe. Both keys must be set.
VAPID_PRIVATE_KEY (unset) Private half. Never leaves the server.
VAPID_SUBJECT (APP_URL) How you identify yourself to the push services: a mailto: or https: URL they can contact you at.

On a fresh install you already have a pair. ./docker/gen-secrets.sh generates one with the rest of your secrets, so push works as soon as members turn it on. Nothing else is needed here — optionally set VAPID_SUBJECT, which the script deliberately leaves empty because it is your contact address rather than a secret.

Upgrading an existing install, or running on a platform where gen-secrets.sh has nothing to write into? Then generate the pair yourself, once. Always pass --show, which prints the keys instead of writing them anywhere:

Terminal window
docker compose exec app php artisan webpush:vapid --show
VAPID_PUBLIC_KEY=BN4Gv…
VAPID_PRIVATE_KEY=hAn5…

Paste both into the .env on the host, alongside your other secrets, and set VAPID_SUBJECT while you are there:

VAPID_PUBLIC_KEY=BN4Gv…
VAPID_PRIVATE_KEY=hAn5…
VAPID_SUBJECT=mailto:[email protected]

Then restart the stack to pick them up:

Terminal window
docker compose up -d

To confirm the setup works, ask the instance rather than waiting for a notification that may never come:

Terminal window
docker compose exec app php artisan push:doctor

It reports on the keypair, the subject, the extensions the push library needs, whether the delivery channel constructs, how many devices are subscribed, and any push jobs that have already failed — and exits non-zero when push cannot be delivered. See Troubleshooting → Nobody receives push notifications.