Viewer guide

Deploy the Viewer

Put the Viewer somewhere your team can reach it. Run the container, give it a stable address, and keep its data on a disk you back up.

This page turns the Viewer into something your team can use: a real address, sign-in, and data that survives a restart. Try the Viewer is a demo on your own machine, with no sign-in and nothing your teammates can reach. Start here instead.

Setting it up is three steps, in this order.

  1. This page: get it running at a stable address, with its data on a disk you back up.
  2. The GitHub App: sign-in, and access to the repositories you want to build. Two clicks from inside the product once step 1 is done.
  3. Email server: optional. Only needed if you want mention notifications to leave the app.

The Viewer is one small server. Everything it keeps lives in one folder: a database and the built files of every prototype it has published. That keeps deployment simple. The two things to get right, where that folder lives and what address the Viewer thinks it has, are the two things that usually go wrong.

Before you start#

Two things to settle before you pick a machine. Securing your Viewer covers both in full, along with roles, prototype access and tokens.

Building a prototype runs that repository's own commands on this machine, the same as any build server you host. Treat the people who can connect a repository as people who can run code there.

It is invite-only, so the address alone gets nobody in. One thing to know anyway: once someone has an account, they can see the GitHub App installations their own GitHub user can see, which includes the names of private repositories.

Warning Put the Viewer on a network your team already trusts: a VPN, a private subnet, an office network, or a machine only your team can reach. Sign-in decides who gets an account; it does not rate limit the rest. If you put it on a public address anyway, put a rate-limiting proxy in front of it.

One licensing note, since it decides nothing else on this page: the Viewer is AGPL-3.0-or-later. Self-hosting it for your team, including commercially, is fine. If you change it and run the changed version for people over a network, the license requires you to make that source available to them. Running it unmodified does not trigger this. This is not legal advice; see the full license text for what it requires.

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

Where to run it#

The Viewer ships as a container image, so the quickest path is a host that runs containers for you: Fly.io, Render or Railway. Give it the image, attach a disk, and you get an address with https on it. Docker on a Linux virtual machine works the same way, and is the way to go if the Viewer has to sit inside your own network.

Where to run it has the hosts, the settings they each need, and the ones that cannot run it, Cloud Run included.

Get the image#

A published image is the shortest path, and it needs no registry login:

docker pull ghcr.io/desde-design/viewer:latest

It is built for both Intel and Apple silicon hosts. Every tag corresponds to a commit in the source repository, which the image carries as org.opencontainers.image.revision. Pin a version tag rather than latest on anything you rely on, so an upgrade is a decision rather than a restart. Upgrading lists the tags that are published and what moving between them involves.

Every docker run line on these pages names that image. If you build your own instead, substitute the tag you gave it.

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). One setting is enough to start, the address your team will open:

VIEWER_PUBLIC_URL=https://proto.internal.example.com

The rest can wait. The session secret is generated and saved in the data volume on first boot, and the GitHub App is set up from inside the product after you sign in. Later pages add to this file when you need them: VIEWER_TRUST_PROXY for a reverse proxy, and the SMTP settings for email.

Then run it:

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

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. That matters only if you register the GitHub App by hand: VIEWER_GITHUB_APP_PRIVATE_KEY accepts a base64-encoded PEM on one line for this reason. Produce it with base64 -i your-app.private-key.pem | tr -d '\n'.

Ports for prototypes#

Most of the time, one published port is enough. Every prototype rides the same port as the viewer itself, so the docker run line above only needs -p 3100:3100.

The exception is someone opening the viewer at a bare localhost address, for example while you are checking the container works before your real domain is pointed at it. That puts each prototype on a port of its own next to the viewer's, so add the twenty ports above it too:

-p 127.0.0.1:3101-3120:3101-3120

If you leave that out and a teammate reaches the viewer at localhost, a prototype opens but never loads, and the review page tells you which flag to add. The 127.0.0.1: prefix keeps those ports on your own machine, where they belong. A prototype opened this way has no sign-in of its own, so anyone who can reach its port can open it.

The image opens those ports to the -p flags; that is set in the image itself as VIEWER_LOOPBACK_BIND=all. With --network host, or under Podman, the -p flags do nothing and the ports would face your network directly, so add -e VIEWER_LOOPBACK_BIND=loopback to the run line to keep them on the machine. The viewer's startup log warns when the setting does not match the network layout it sees.

On a server reached by a hostname, prototypes use subdomains instead; see Where prototypes are served.

Check it came up. This endpoint needs no credential:

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

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

Sign in as the first Admin#

Until GitHub sign-in is set up, the Viewer prints a sign-in link as the last line of its startup log. Read it with:

docker logs desde-viewer

Open that link in a browser, and you are signed in as an Admin. The link is built from VIEWER_PUBLIC_URL, so it works once that address reaches the Viewer. Anyone who can read the container's logs can use it, so go straight on to the GitHub App next. Once GitHub sign-in works, the link stops working and no new one is printed.

If you register the GitHub App by hand and put its settings in viewer.env before the first boot, no link is printed. The first person to sign in with GitHub becomes the Admin instead.

VIEWER_PUBLIC_URL is the one that breaks silently#

The image does not set VIEWER_PUBLIC_URL, and the default is http://desde.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://desde.localhost:3100, which resolves to whichever machine that person is on, not your server, so sign-in fails.
  2. The Secure flag on session cookies. The flag is set if and only if VIEWER_PUBLIC_URL starts with https://. See HTTPS and a reverse proxy.
  3. The address prototypes talk back to. Every served prototype sends comments and inspector data to this origin. A wrong value means comments and the inspector quietly do not work inside a prototype.

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 ghcr.io/desde-design/viewer:latest id node

Set up the rest#

Each of these is its own page. Read the first two before you hand the address to your team.

Then Securing your Viewer, before you hand the address to your team.

What these pages do 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 listed under Before you start; 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.