Desde

Self-hosting

Deploy the Viewer

Run the Desde Viewer in production. Docker image, reverse proxy, backups, disk growth, systemd, and the security posture it assumes.

The Viewer is one Node process. It stores everything in a single directory: a SQLite database and the built assets of every prototype it has published. That makes deployment simple, and it makes the two things you have to get right (where that directory lives, and what URL the process thinks it is reachable at) the only two things that usually go wrong.

This page covers a real deployment. If you have not run the Viewer at all yet, start with Run the Viewer: it walks the GitHub App registration and the first-project flow that this page assumes you have already done.

Read this before you deploy anything#

Three properties decide where the Viewer can safely live.

The build runner executes repository code. When you connect a repository and trigger a build, the Viewer clones it and runs that repository's own install and build commands through a shell, on the Viewer host, as the Viewer process. This is deliberate (the same trust model as self-hosted CI), but it is not a sandbox. The runner narrows the blast radius (the child gets a minimal environment rather than the Viewer's, it runs in its own process group so a timeout kills the whole tree, output is byte-capped, and the GitHub token is scrubbed from logs) but anyone who can connect a repository can run code on that machine.

Rate limiting covers only the unauthenticated write lanes. A built-in per-IP limiter throttles sign-in (/auth/**), comment writes, POST /projects/resolve, and participant invites. Everything else, including authenticated API traffic and the prototype-serving layer, is unthrottled; put anything more in the proxy in front. Behind a reverse proxy you must also set VIEWER_TRUST_PROXY, or every request keys to the proxy's address and the per-IP limits collapse into one shared bucket.

The Viewer is invite-only. Completing the GitHub OAuth flow does not by itself create an account. A separate check decides that, and refuses anyone who isn't invited, isn't covered by a domain rule, and isn't the first person on a brand-new instance. See members and tokens for the exact rule. Once someone does have an account, they can see the GitHub App installations their own GitHub user can see, which includes the names of private repositories. That part is unchanged.

Warning Put the Viewer on a trusted network: a VPN, a private subnet, an office network, or a machine reachable only by your team. It is not built to sit on the open internet. Invite-only sign-in narrows who gets an account; it does not add rate limiting, and it does not stop an invited account from seeing more of your GitHub org than you intended. If you expose it publicly anyway, put a rate-limiting proxy in front of it.

The Viewer has never been used by an external team. Treat everything below as a first deployment, not a hardened recipe.

How prototypes are isolated from your session#

A prototype is untrusted code someone uploaded or built from a repo. The Viewer keeps it from reading your session cookie and from reaching into your browser tab. It does this with one of four origin modes, picked automatically from your config at boot. No setting chooses a mode directly; the mode falls out of VIEWER_PUBLIC_URL, VIEWER_SERVE_DOMAIN, and VIEWER_PROTOTYPE_ORIGIN. When more than one condition is met, this is the order they're tried in: subdomain first, then the single alternate origin, then loopback, then fallback last.

Mode Picked when Where the prototype is served Storage inside the prototype
Loopback VIEWER_PUBLIC_URL is unset, or points at localhost, 127.0.0.1, or [::1], and loopback listeners are available (see below) The other loopback name, on its own port, opened the first time someone reviews that deployment Works. Real origin.
Subdomain VIEWER_SERVE_DOMAIN is set {slug}.{VIEWER_SERVE_DOMAIN}, at the origin root Works for every access level. Real origin. A public-link project needs no credential; an all-members or invited project gets one carried on the document load, see the note below.
Single alternate origin VIEWER_PROTOTYPE_ORIGIN is set (and no serve domain) That one origin, at /p/{slug}/, shared by every prototype Works. Real origin, but shared. Every prototype uses the same one, so they share storage and a cookie jar and can script each other.
Fallback None of the above, for example a bare IP or a hostname with no wildcard DNS The same host as the Viewer itself, at /p/{slug}/, sandboxed Does not work. The prototype has no origin of its own.

Loopback mode is for local development. Subdomain mode is the one to set up for a real deployment: it needs one wildcard DNS record and a matching wildcard TLS certificate for *.{VIEWER_SERVE_DOMAIN}, set up once alongside the rest of your DNS, never asked for again per project. See serving for how VIEWER_SERVE_DOMAIN is configured.

When you cannot add a wildcard: VIEWER_PROTOTYPE_ORIGIN#

Read the tradeoff first. Subdomain mode is stronger, and it is the mode to use whenever you can. VIEWER_PROTOTYPE_ORIGIN exists for the one case subdomain mode cannot cover: a deployment where you can add one more DNS name and one more certificate SAN, but not a wildcard. Set it to a single alternate origin, for example https://prototypes.example.net, and every prototype is served from that one origin, cross-origin from the Viewer's own shell.

The cost is that all prototypes share that single origin. They share localStorage, IndexedDB, and a cookie jar with each other, and one prototype's JavaScript can script another's. Subdomain mode does not have this, because each prototype gets its own registrable host. So use VIEWER_PROTOTYPE_ORIGIN only when a wildcard is genuinely unavailable, and prefer VIEWER_SERVE_DOMAIN.

To set it up: add one DNS name pointing at the same Viewer (an A/AAAA or CNAME record), add that name to the certificate as a SAN, and set VIEWER_PROTOTYPE_ORIGIN to its full origin. The origin must use the same scheme as VIEWER_PUBLIC_URL and must be on a different registrable domain than the shell. A shell at app.example.com with a prototype origin at prototypes.example.net is fine; prototypes.example.com is not, because a prototype there could set a cookie the shell would receive. The Viewer refuses an unsafe value at boot: an origin equal to the shell's, a different scheme (mixed content), or a same-site sibling.

A public-link project serves correctly on its subdomain with no credential at all. An all-members or invited project gets a short-lived capability appended to its document load (?~c={token}). The Viewer verifies it and sets it as a host-only dsv_cap cookie on the prototype's own subdomain, so every later same-site request from that document, its assets included, is authorized without the Viewer's session cookie ever being involved. On an https deployment that cookie is actually named __Host-dsv_cap: the __Host- prefix is what locks it to this exact host over a secure connection. Plain dsv_cap is used only on http, since a browser rejects a __Host- cookie that isn't marked Secure. See projects for the project-access side of this.

This needs VIEWER_SERVE_DOMAIN to be same-site with VIEWER_PUBLIC_URL. "Same-site" means the two share a registrable domain: app.example.com for the Viewer and proto.example.com for VIEWER_SERVE_DOMAIN both share example.com, so this works. A shell at example.com and a serve domain at other.net do not share one, and the browser then silently withholds the dsv_cap cookie: a private prototype's HTML loads, then every one of its assets 404s inside the review iframe. There is no boot-time check for this; a public suffix list would be needed to check it correctly, and none is built in.

Fallback mode's review iframe carries sandbox="allow-scripts allow-forms". Loopback and subdomain mode add allow-same-origin, because the prototype is already on a different origin there, so restoring its own origin gives it nothing that reaches your session.

Note The Viewer only answers a Host header naming one of a closed set built from its own config: VIEWER_PUBLIC_URL's host, its own loopback address on PORT, and {slug}.VIEWER_SERVE_DOMAIN when a serve domain is set. Reaching it through a LAN IP, or through a proxy that rewrites Host, now gets 400 Unexpected host instead of being served. If that happens to you, set VIEWER_PUBLIC_URL to the name people actually type.

Boot prints which mode is active, so you can confirm it picked what you expected. In fallback mode it also warns: a prototype built with a root-absolute asset base will not fully load for a signed-in member there, and the warning names both fixes (set VIEWER_SERVE_DOMAIN, or build the prototype with a relative base).

Loopback mode only works when the browser is on the same machine as the Viewer. A loopback listener binds an address on the Viewer's own host. A browser reaching the Viewer through a published container port, or from a separate remote machine, cannot reach that listener, even though the Viewer itself answered fine.

The Viewer detects this automatically and falls back. At boot it checks VIEWER_LOOPBACK_LISTENERS, which defaults to auto:

  • auto (the default). The Viewer looks for /.dockerenv or /run/.containerenv. If either file exists, it assumes it is in a container and does not open loopback listeners. Prototypes fall back to same-host path mode instead (fallback mode, from the table above).
  • off. Never open loopback listeners, regardless of what the container check finds.
  • on. Always open loopback listeners. Use this only when the browser genuinely shares the Viewer's network namespace, for example a container run with --network host.

This check is a heuristic, not a guarantee. A container runtime that writes neither marker file still looks like a normal host, and the Viewer will try loopback mode and fail. If that happens, set VIEWER_LOOPBACK_LISTENERS=off by hand.

For a real deployment, in a container or on a remote server, do not rely on loopback mode at all: set VIEWER_SERVE_DOMAIN (subdomain mode), or a non-loopback VIEWER_PUBLIC_URL. That gives every prototype a real, isolated origin instead of the degraded same-host fallback.

For loopback mode's boundary (ports are reachable by any process on the machine, and prototypes on one loopback host share cookies with each other across ports), and the full mechanics behind fallback mode's iframe sandbox and content-security-policy, see viewer/README.md's "How prototypes are isolated" section.

Build the image#

There is a Dockerfile at viewer/Dockerfile. Two things about it are unusual and will bite you if you skip them.

Build the bridge first#

The bridge (the script the Viewer injects into every prototype it serves) is built from source into dist/bridge-bundle.js at the repo root. That file is committed: .gitignore ignores dist/* but un-ignores the bundle on the next line, so a fresh clone does have one. What a clone has no guarantee of is a current one: it is a build artifact, the Dockerfile does not rebuild it, and nothing checks it against src/bridge/. Rebuild it before you build the image, or you ship whatever bridge was last committed.

The Viewer reads that file at boot with an unguarded readFileSync, so if the bundle is ever missing outright (you deleted it, or an ignore rule stripped it from the build context), you get an image that builds cleanly and then exits on startup.

From the repo root:

npm install
npm run build:bridge

The build context is the repo root#

The Dockerfile copies from repo-root paths (viewer/package.json and the root package.json), because the Viewer's Next.js dashboard resolves next and react from the checkout root rather than vendoring its own copies. So you build from the repo root and point -f at the Dockerfile, not the other way around:

DOCKER_BUILDKIT=1 docker build -f viewer/Dockerfile -t desde-viewer .

Running docker build . from inside viewer/ fails: the root package.json is not in that context.

Why DOCKER_BUILDKIT=1 is in that command#

The ignore rules for this build live in viewer/Dockerfile.dockerignore. Naming an ignore file <Dockerfile>.dockerignore next to its Dockerfile is a BuildKit feature. The classic builder looks only for a .dockerignore at the context root, and this repo does not have one.

So if the build runs on the classic builder (an old Docker Engine, or DOCKER_BUILDKIT=0), nothing is excluded and COPY . . copies your entire working checkout into the image. That includes .git, every node_modules tree, and viewer/.env.local, which holds your GitHub App private key, the App client secret, the session secret, and your SMTP password.

BuildKit is the default in Docker Desktop, which bundles the buildx plugin BuildKit needs.

Note On a Linux host, DOCKER_BUILDKIT=1 can fail the build outright:

ERROR: BuildKit is enabled but the buildx component is missing or broken.

The variable does not turn on a builder that is already there. It selects one that ships as a separate plugin, and an engine without it stops rather than falling back. Install the plugin (apt install docker-buildx-plugin, or your distribution's equivalent), or drop the variable and read the paragraph below: the classic builder produces a correct image, it just copies more into the context on the way.

The build does not rely on you getting that right. After COPY . ., the Dockerfile asserts that no .env, .env.local, .env.*.local or .env.production reached the build context, and fails the build if one did. The ignore file is the optimization; the assertion is the control. (.env.example is exempt: it is a tracked template with no real values.) To confirm after a build anyway:

docker run --rm desde-viewer ls -a /app/viewer

There should be no .env.local in that listing.

Run the container#

The image bakes in three things: NODE_ENV=production, PORT=3100, and VIEWER_DATA_DIR=/data. It exposes port 3100, declares /data as a volume, and runs as the unprivileged node user rather than root, because the build runner executes untrusted repository code in this container.

Everything else must be passed in as container environment. The image's start command does not read a .env file of any kind, so viewer/.env.local inside the image (if it even got there) would be ignored. Use -e or --env-file.

Write an env file on the host (call it viewer.env):

VIEWER_PUBLIC_URL=https://proto.internal.example.com
VIEWER_GITHUB_CLIENT_ID=Iv1.xxxxxxxxxxxx
VIEWER_GITHUB_CLIENT_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
VIEWER_SESSION_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
VIEWER_GITHUB_APP_ID=123456
VIEWER_GITHUB_APP_SLUG=acme-desde
VIEWER_GITHUB_APP_PRIVATE_KEY=LS0tLS1CRUdJTiBSU0EgUFJJVkFURSBLRVktLS0tLQo...
VIEWER_ALLOWED_EMAIL_DOMAINS=example.com

Then run it:

docker run -d --name desde-viewer -p 3100:3100 -v desde-viewer-data:/data --env-file viewer.env desde-viewer

Note Docker's --env-file is not a shell. It does no variable expansion, and a quoted value keeps its quotes as part of the value. It also cannot carry a multi-line value, which is why VIEWER_GITHUB_APP_PRIVATE_KEY accepts a base64-encoded PEM on one line. Produce it with base64 -i your-app.private-key.pem | tr -d '\n'.

Check it came up. This endpoint needs no credential:

curl http://localhost:3100/api/v1/health

It answers {"status":"ok","profile":"selfhost"}.

VIEWER_PUBLIC_URL is the one that breaks silently#

The image does not set VIEWER_PUBLIC_URL, and the default is http://localhost:3100. In any real deployment that default is wrong, and the failures it causes do not look like configuration failures.

VIEWER_PUBLIC_URL is used for three separate things:

  1. The OAuth redirect URI. The sign-in flow sends GitHub ${VIEWER_PUBLIC_URL}/api/v1/auth/github/callback. Left at the default, GitHub is told to send the user back to http://localhost:3100, which either fails outright or lands them on their own machine.
  2. The Secure flag on session cookies. The flag is set if and only if VIEWER_PUBLIC_URL starts with https://. See behind a reverse proxy below.
  3. The bridge's shell origin. It is the origin every served prototype's injected bridge posts messages to. A wrong value means comments and the inspector quietly do not work inside the iframe.

Set it to the exact origin your users type, with no trailing path. It is validated as an absolute http(s) URL at boot, so a typo in the scheme fails loudly, but a valid wrong URL does not.

The data volume#

Everything durable lives under VIEWER_DATA_DIR, which is /data in the image:

/data
├── viewer.db          SQLite: projects, deployments, comments, users, sessions, tokens
├── viewer.db-wal      write-ahead log
├── viewer.db-shm      shared-memory index
└── assets/
    └── <deploymentId>/…   the published files of one build

The example above uses a named volume (-v desde-viewer-data:/data). Prefer that. The container runs as the node user, and /data is chowned to it in the image, so a named volume inherits workable ownership on first use.

A bind mount (-v /srv/desde:/data) does not inherit anything: it keeps the host directory's ownership, and if that is not writable by the container's node user, SQLite fails on the first write. If you need a bind mount, find the uid the image runs as and chown to match:

docker run --rm desde-viewer id node

Behind a reverse proxy#

The Viewer speaks plain HTTP and terminates nothing. In production you put a TLS-terminating proxy in front of it. There is one non-obvious rule.

Warning Set VIEWER_PUBLIC_URL to the https URL. The Viewer does not read X-Forwarded-Proto for this: it decides whether to mark the session cookie Secure purely by checking whether VIEWER_PUBLIC_URL starts with https://. Leave it on an http:// value behind an HTTPS proxy and your session cookies ship without Secure, meaning a browser will send them over plain HTTP too.

Also set VIEWER_TRUST_PROXY (for one proxy: 1) whenever a reverse proxy terminates TLS in front of the Viewer. It feeds Express's trust proxy, which is what lets the built-in rate limiter see real client addresses instead of the proxy's. The literal value true is refused at boot because it would trust a client-supplied header.

The session cookie is HttpOnly and SameSite=Lax regardless; Secure is the only part that depends on this.

An nginx server block:

server {
  listen 443 ssl;
  server_name proto.internal.example.com;
 
  ssl_certificate     /etc/ssl/certs/proto.crt;
  ssl_certificate_key /etc/ssl/private/proto.key;
 
  location / {
    proxy_pass http://127.0.0.1:3100;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-Proto $scheme;
 
    # Required by VIEWER_TRUST_PROXY. See the warning below.
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
 
    # Build logs and comment updates are server-sent events.
    proxy_buffering off;
    proxy_read_timeout 15m;
  }
}

Warning proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; is not optional once you set VIEWER_TRUST_PROXY. X-Forwarded-For is the header trust proxy reads, and nginx does not set it on its own.

Leave it out and you get worse than the shared-bucket problem VIEWER_TRUST_PROXY exists to fix. nginx passes a client's own headers through to the upstream, so a caller can send their own X-Forwarded-For, and the Viewer, told to trust one proxy, will believe it. Rotating that header then gives a fresh rate-limit bucket per request on the sign-in and comment-write lanes.

$proxy_add_x_forwarded_for appends the real peer address to whatever the client sent, which is what makes the value trustworthy at the hop count you configured.

The last two lines matter. Build logs and comment updates stream over server-sent events, so a buffering proxy holds the log until the build finishes and shows the user a frozen panel. And a single build step is allowed up to ten minutes, so a default 60-second read timeout will cut the stream mid-build on any real project.

If you use VIEWER_SERVE_DOMAIN to serve each prototype on its own subdomain, the proxy needs a wildcard certificate and a wildcard server_name for that domain, and it must pass the original Host header through: the Viewer routes on it. See serving.

Running as a service without Docker#

There is no bundled process manager and no published package: the Viewer runs from a monorepo checkout. A systemd unit is the shortest path on a Linux host.

Prepare the checkout once, at /opt/desde:

npm install
npm install --prefix viewer
npm run build:bridge
npm run build --prefix viewer

The last command is next build. It is required: the start script sets NODE_ENV=production, which makes the process serve the prebuilt .next directory instead of compiling on demand. Skip it and routes go missing with no error.

Note that start does not read viewer/.env.local; only the :local scripts do. So systemd supplies the environment:

[Unit]
Description=Desde Viewer
After=network-online.target
Wants=network-online.target
 
[Service]
Type=simple
User=desde
WorkingDirectory=/opt/desde
EnvironmentFile=/etc/desde/viewer.env
ExecStart=/usr/bin/npm run start --prefix viewer
Restart=on-failure
RestartSec=5
 
[Install]
WantedBy=multi-user.target

Put that at /etc/systemd/system/desde-viewer.service, then:

sudo systemctl enable --now desde-viewer

A few notes on that unit:

  • Set VIEWER_DATA_DIR to an absolute path in the environment file. Its default, .desde-viewer, is relative and resolves against the working directory. Start the process from two different directories and you get two different databases with no warning.
  • Confirm the npm path with which npm: it is not /usr/bin/npm on every distribution or under a version manager.
  • EnvironmentFile has the same one-line-values constraint as Docker's --env-file, so use the base64 form of the App private key here too.
  • The process handles SIGTERM and shuts down cleanly: it marks any in-flight build failed (otherwise it stays building forever in the UI) and closes the database. Do not configure KillMode=process or a short TimeoutStopSec.

Backups#

The database runs in WAL mode (write-ahead logging). Recent writes may live in viewer.db-wal and not yet be inside viewer.db.

Warning Copying viewer.db on its own can lose recent comments, projects and sessions, and can produce a file that will not open. Back up the whole VIEWER_DATA_DIR, including the -wal and -shm sidecars.

The simple, always-correct procedure is to stop the process first:

docker stop desde-viewer
tar czf viewer-backup-$(date +%F).tar.gz -C /var/lib/docker/volumes/desde-viewer-data/_data .
docker start desde-viewer

Note /var/lib/docker/volumes/<name>/_data is where a named volume lives on a Linux Docker Engine host. On Docker Desktop the volume is inside a virtual machine and that path does not exist on your Mac or Windows filesystem. Ask Docker where it is with docker volume inspect desde-viewer-data, or copy the data out through a throwaway container instead.

If you cannot take downtime and you have the sqlite3 command-line tool on the host (the Viewer does not ship it: storage uses Node's built-in node:sqlite), sqlite3 /data/viewer.db ".backup /path/to/viewer-backup.db" takes a consistent online copy of the database. Back up assets/ separately with a plain file copy; asset files are written once and never rewritten, so copying them while the process runs is safe.

To restore, stop the process, replace the contents of VIEWER_DATA_DIR, and start it again.

Disk growth#

Each successful build publishes its output under <VIEWER_DATA_DIR>/assets/<deploymentId>/, up to a 200 MB cap per deployment. A build that exceeds the cap fails rather than publishing a partial deployment.

Nothing prunes old deployments. There is no retention policy and no API to delete a deployment. Every build you ever run stays on disk until you remove it by hand. A project rebuilt daily accumulates a full copy of its build output every day.

Serving only ever reads a project's active deployment, so removing the asset directory of any non-active deployment is safe for the live prototype. To find out which ones those are, list your projects and note each activeDeploymentId:

curl -H "Authorization: Bearer dsv_your_token_here" http://localhost:3100/api/v1/projects

Then remove the asset directories you do not need:

rm -rf /var/lib/docker/volumes/desde-viewer-data/_data/assets/<deploymentId>

The deployment's row stays in SQLite, so it still appears in the project's deployment history with its build log. Only its files are gone. That deployment can no longer be re-activated and served.

Warning Never remove the directory named by a project's activeDeploymentId. The prototype stops loading, and the only recovery is another build.

Watch du -sh on the assets directory as part of whatever monitoring you already run. There is no disk-usage warning inside the product.

Upgrading#

Rebuilding is the whole procedure. Nothing in VIEWER_DATA_DIR is touched by an image rebuild.

The database schema is created with CREATE TABLE IF NOT EXISTS on every boot, so a new table added by an upgrade appears automatically. Beyond that, versioned migrations run at boot too: each one tracked in SQLite's PRAGMA user_version and applied at most once, ever. There is no down-migration. Take a backup before upgrading.

git pull
npm install && npm install --prefix viewer && npm run build:bridge
DOCKER_BUILDKIT=1 docker build -f viewer/Dockerfile -t desde-viewer .
docker stop desde-viewer && docker rm desde-viewer

Then re-run the docker run command from above. Because all state is in the volume, the new container picks up exactly where the old one left off.

One thing to leave alone across upgrades: VIEWER_SESSION_SECRET. It signs session cookies, so changing it signs everyone out.

Upgrading from before instance membership existed#

If your instance predates instance roles (Admin / Editor / Viewer), migration 1 backfills them the first time you boot the new version:

  • the oldest human account becomes Admin: the local-operator row (operator@localhost) is never counted as "oldest" here, even though it is usually the very first row a zero-config instance ever wrote;
  • the local-operator row itself is separately set to Admin, since it is admin by definition;
  • every other existing account becomes Editor: this preserves what they could already do (create and manage projects), since there were no per-project roles to narrow it from.

Both promotions are logged to stdout so you can see exactly which account(s) just gained instance authority. Review Settings → Members after upgrading and adjust anyone who should not have Admin or Editor.

Before upgrading a production instance, it's worth setting VIEWER_ADMIN_TOKEN (if you haven't already) as a recovery path: it's a bearer credential independent of the migration's account role, so you can still manage members and fix a role from the API even if the backfill did not land the way you expected.

What this page does not cover#

  • Horizontal scaling. The Viewer is one process with a local SQLite file and a local asset directory. Running two of them against the same volume is not something the product supports or has been tested for.
  • A backup schedule, monitoring, or alerting. None of it is built in.
  • A WAF, or rate limiting beyond the built-in one. The built-in limiter covers only the unauthenticated write lanes described above; anything broader belongs in the proxy in front.
  • The full environment-variable list. Every variable, including SMTP for mention emails and the prototype content-security-policy, is in viewer configuration.