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_HOSTmakesVIEWER_SMTP_USER,VIEWER_SMTP_PASSandVIEWER_SMTP_FROMrequired
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.exampleAn 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_DIRto 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-viewerInside 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:
- The
Secureflag on session cookies. The Viewer setsSecureif and only ifVIEWER_PUBLIC_URLstarts withhttps://. - The OAuth redirect URI (
<VIEWER_PUBLIC_URL>/api/v1/auth/github/callback), which must match the callback URL registered on your GitHub App exactly. - 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.
- The origin the Content-Security-Policy
connect-srcdirective 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. SeeresolvePrototypeCspinviewer/server/serve/serve-router.ts, fed by the per-requestshellOrigincomputed inviewer/server/create-app.ts.
Warning If you terminate TLS at a proxy and leave
VIEWER_PUBLIC_URLonhttp://, the Viewer will emit session cookies without theSecureflag 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_URLmust sayhttps://.
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
offoutside 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 inconnect-src, or turn on subdomain serving withVIEWER_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.
Related#
- Deploying the Viewer: registering the GitHub App and a first end-to-end run
- Serving: path serving, subdomain serving, and what each one fixes
- HTTP API
- Troubleshooting