Desde

Reference

Viewer configuration

Every environment variable the Viewer reads, with its default, its effect, and the three groups that are all-or-nothing at boot.

The Viewer is configured entirely through environment variables. It reads them once, at boot, in viewer/server/config.ts, and validates them there. A bad value stops the process with a message naming the variable rather than surfacing later as strange behaviour.

For local runs, copy viewer/.env.example to viewer/.env.local and start with npm --prefix viewer run dev:local, which loads that file. In production you set these however your process manager sets environment variables.

The rule that catches people first#

Most variables are independent. Three groups are not: set any one member and the rest of that group becomes required, and a missing one throws at boot.

  • Sign-in: VIEWER_GITHUB_CLIENT_ID, VIEWER_GITHUB_CLIENT_SECRET
  • Repositories: VIEWER_GITHUB_APP_ID, VIEWER_GITHUB_APP_PRIVATE_KEY, VIEWER_GITHUB_APP_SLUG
  • Email: setting VIEWER_SMTP_HOST makes VIEWER_SMTP_USER, VIEWER_SMTP_PASS and VIEWER_SMTP_FROM required

This is deliberate. A Viewer that boots with half-configured sign-in is worse than one that refuses to boot, because the failure surfaces as a broken button several minutes later.

The two GitHub groups are independent of each other, even though in practice you fill both in from the same GitHub App. Sign-in works with no App keys configured, and vice versa.

Core#

Variable Required Default Effect
PORT Optional 3100 TCP port the process listens on. Must be a positive integer or boot fails.
VIEWER_PUBLIC_URL Optional http://localhost:<PORT> The absolute origin the Viewer is reachable at. Validated as an absolute http(s) URL. Trailing slashes are stripped. See what it controls below.
VIEWER_DATA_DIR Optional .desde-viewer Root for the SQLite database and built prototype assets. See the warning below.
VIEWER_PROFILE Optional selfhost Selects which infrastructure the process wires up. Currently a union of one value. Any other value stops boot.
VIEWER_ADMIN_TOKEN Optional unset A static bearer token that reaches every project regardless of membership. Unset does not disable writes (see members and tokens).
VIEWER_SESSION_SECRET Optional generated, persisted HMAC key that signs session cookies. If unset, the Viewer generates one on first boot and saves it to $VIEWER_DATA_DIR/config.json, so it is never actually unset once the server has run. Set it yourself to force every existing session to sign out at once, or to share one secret across a multi-process deployment.
VIEWER_SERVE_DOMAIN Optional unset Turns on subdomain serving at {slug}.<domain> alongside /p/{slug}/. Needs wildcard DNS and a wildcard certificate. See serving.
VIEWER_PROTOTYPE_ORIGIN Optional unset A single alternate origin that serves all prototypes, cross-origin from the shell, for a deployment that can add one DNS name and cert SAN but not a wildcard. Weaker than VIEWER_SERVE_DOMAIN (all prototypes share the one origin). Must share the scheme of VIEWER_PUBLIC_URL and be on a different registrable domain than it. See prototype isolation.
VIEWER_LOOPBACK_LISTENERS Optional auto Loopback origin mode: auto, on, or off. auto opens per-prototype loopback listeners when the shell is reached on a loopback host, and falls back to path serving inside a container. Any other value stops boot.
VIEWER_TRUST_PROXY Optional unset How many reverse-proxy hops sit in front of the server, or a comma-separated list of proxy addresses to trust. Set it when TLS terminates at a reverse proxy; leaving it unset there collapses per-IP rate limits into one shared bucket. The literal value true is refused at boot.
VIEWER_DEMO_PROJECT Optional on Seeds a demo prototype on an empty first boot so a fresh instance has something to open. Set off to skip it.
VIEWER_ALLOWED_EMAIL_DOMAINS Optional unset Comma-separated list of domains, seeded once into stored domain rules at boot. See below (the Viewer is invite-only regardless of this variable).
VIEWER_DEV_BUNDLER Optional turbopack Development only. turbopack or webpack. Switch to webpack if the dev server crashes complaining about a symlink outside the filesystem root.
VIEWER_PROTOTYPE_CSP Optional unset Content-Security-Policy sent on served prototype HTML. See the escape hatch below.
NODE_ENV Optional unset Anything other than production runs Next.js in development mode. The npm --prefix viewer run start script sets it to production, which serves a prebuilt .next directory.

VIEWER_ALLOWED_EMAIL_DOMAINS#

The Viewer is invite-only. This variable does not open sign-in the way it used to.

Here is what it does now. The server keeps a permanent marker in its instance settings, called domainRulesSeededFromEnv. The first boot that has not set this marker yet checks the variable. If the variable is set, and the stored domain-rule table is still empty, each domain-only entry (no @) is copied in once as a stored domain rule with role viewer. The server then sets the marker, so this only happens once, ever.

The marker exists to close a specific hole. Say an admin deletes every domain rule later, then restarts the server. Without the marker, the empty table would trigger the copy again, and the deleted rules would silently come back. The marker stops that: once it is set, the variable is ignored completely, no matter what the table looks like.

An admin manages the domain list from Settings after that, not from the environment.

Entries are lowercased and a leading @ is stripped, so @example.com, example.com and EXAMPLE.COM all behave identically:

VIEWER_ALLOWED_EMAIL_DOMAINS=example.com,other-team.example

An entry containing @ is an exact address, not a domain, and it cannot become a domain rule. It is skipped, and the boot log names it and tells you to invite that address instead. See members and tokens for invites.

Domain matching is exact, so notexample.com never matches example.com. A value that is set but parses to nothing (VIEWER_ALLOWED_EMAIL_DOMAINS= or ,,,) is treated as unset, so a stray empty assignment seeds nothing rather than locking everyone out.

GitHub App#

One GitHub App does both jobs: it signs people in, and (once installed on the repositories you want to deploy) it is what the Viewer clones with. The client id and secret come from that App's own settings page, not from a separate OAuth App. Registering it is covered in deploying the Viewer.

Variable Required Default Effect
VIEWER_GITHUB_CLIENT_ID Sign-in group unset The App's OAuth client id.
VIEWER_GITHUB_CLIENT_SECRET Sign-in group unset The App's OAuth client secret.
VIEWER_GITHUB_APP_ID Repositories group unset Numeric App id.
VIEWER_GITHUB_APP_PRIVATE_KEY Repositories group unset The App's private key, as a literal PEM or base64-encoded PEM. See below.
VIEWER_GITHUB_APP_SLUG Repositories group unset The App's URL slug, from https://github.com/apps/<slug>. Used to link people at the install page.
VIEWER_GITHUB_APP_WEBHOOK_SECRET Optional unset Enables push-triggered builds. Unset means POST /api/v1/webhooks/github answers 503 rather than accepting an unverified payload.
VIEWER_GITHUB_API_BASE_URL Optional GitHub.com GitHub Enterprise Server API base. Shared by sign-in and repositories: there is one override, not two.
VIEWER_GITHUB_AUTHORIZE_URL Optional GitHub.com GitHub Enterprise Server authorize endpoint.
VIEWER_GITHUB_TOKEN_URL Optional GitHub.com GitHub Enterprise Server token endpoint.

The three Enterprise overrides are each validated independently when present, and never participate in an all-or-nothing group. VIEWER_GITHUB_AUTHORIZE_URL and VIEWER_GITHUB_TOKEN_URL are read only inside the sign-in block, so they are inert when the sign-in trio is absent. VIEWER_GITHUB_API_BASE_URL is read by both blocks, so it applies (and is validated) as soon as either trio is configured.

The private key#

Multi-line values are painful in most process managers, so the Viewer accepts the downloaded .pem in either form. It detects which by looking for the literal -----BEGIN prefix after trimming. A base64 alphabet can never produce that prefix, so no second variable is needed.

base64 -i ~/Downloads/your-app.private-key.pem | tr -d '\n'

Either way, the result is parsed as a private key before boot completes. A truncated paste, a public key, or garbage fails immediately with a message naming the variable, not on the first GitHub API call hours later.

Email and unsubscribe#

Variable Required Default Effect
VIEWER_SMTP_HOST Optional unset Setting it turns on mention emails and makes the next three required.
VIEWER_SMTP_PORT Optional 587 Must be a positive integer.
VIEWER_SMTP_USER Email group unset SMTP username.
VIEWER_SMTP_PASS Email group unset SMTP password.
VIEWER_SMTP_FROM Email group unset The From address on mention emails.
VIEWER_UNSUBSCRIBE_SECRET Optional unset HMAC key for signing unsubscribe links. Unset means the unsubscribe endpoints answer 404.

With no SMTP host configured, mention notifications are recorded but never sent. Commenting itself is unaffected either way.

VIEWER_DATA_DIR is relative to the working directory#

The default is .desde-viewer, and it is joined onto the path as given. A relative value resolves against the current working directory of the process, not against the viewer directory and not against your repository root.

That means starting the Viewer from two different directories gives you two different databases, with two different sets of projects, and nothing will tell you that has happened. The second one simply looks empty.

Warning Set VIEWER_DATA_DIR to an absolute path on any deployment you care about. Under a process manager or a container the working directory is rarely what you assume, and a service that restarts in a different directory comes back with an empty project list while the real data sits untouched somewhere else.

VIEWER_DATA_DIR=/var/lib/desde-viewer

Inside that directory the Viewer creates viewer.db (SQLite) and assets/ (built prototype files). Both are the entirety of its state. Back up the directory, and you have backed up the Viewer.

VIEWER_PUBLIC_URL does more than you think#

This one value feeds four separate things:

  1. The Secure flag on session cookies. The Viewer sets Secure if and only if VIEWER_PUBLIC_URL starts with https://.
  2. The OAuth redirect URI (<VIEWER_PUBLIC_URL>/api/v1/auth/github/callback), which must match the callback URL registered on your GitHub App exactly.
  3. The origin the injected bridge posts messages to. The bridge is the script the Viewer injects into your prototype so the review UI can talk to it. A wrong value here shows up as comments and the inspector silently not working.
  4. The origin the Content-Security-Policy connect-src directive is scoped to, on every response served under a prototype's /p/{slug}/ prefix. That scoping is what stops a hosted prototype's own JavaScript from calling the Viewer's API as the signed-in reviewer. See resolvePrototypeCsp in viewer/server/serve/serve-router.ts, fed by the per-request shellOrigin computed in viewer/server/create-app.ts.

Warning If you terminate TLS at a proxy and leave VIEWER_PUBLIC_URL on http://, the Viewer will emit session cookies without the Secure flag while the browser is on HTTPS. The site appears to work. The cookie is then eligible to be sent over a plaintext connection. Whenever a browser reaches the Viewer over HTTPS, VIEWER_PUBLIC_URL must say https://.

VIEWER_PROFILE#

selfhost (SQLite plus local disk) is the only value. The type is a union of one on purpose: it keeps the profile an explicit, validated concept with a place for a second backend to plug in, rather than a setting that quietly does nothing. Leave it unset. Any other value stops the process at boot with Unknown VIEWER_PROFILE.

VIEWER_PROTOTYPE_CSP#

Three states:

  • Unset (the default): the serve layer computes a policy scoped to the prototype's own path prefix.
  • The literal string off: no CSP header is sent at all.
  • Any other string: sent verbatim as the header value.

An empty or whitespace-only value is normalised to unset, because an empty Content-Security-Policy: header is ignored by browsers. The variable would look configured while protecting nothing.

Warning Do not use off outside local debugging. Under path serving, prototypes run on the same origin as the Viewer. With no CSP, a hosted prototype's JavaScript can call the Viewer's own API as whoever is currently signed in and viewing it, including minting a personal access token that survives that person's logout. Prefer a custom policy that allow-lists what your prototype actually needs in connect-src, or turn on subdomain serving with VIEWER_SERVE_DOMAIN.

What the build step does not inherit#

When the Viewer builds a connected repository it does not hand your process environment to the build. The child gets an allowlist: PATH, HOME, TMPDIR, CI=true, a couple of git variables that stop it prompting, and NODE_ENV=production on the build step only.

So a VITE_API_URL you exported for the Viewer process will not reach your prototype's build. Commit the values your build needs, or use your framework's own .env files inside the repository.