# Run the web interface by hand

Previous: [Docker Compose](/docs/manual/docker-compose.md) · [Manual setup](/docs/manual.md) · Next: [certificates and credentials](/docs/manual/certificates.md)

The web interface (`apps/web`) runs only in Docker, as a Compose project of its
own beside a Scribe cluster. This guide starts it by hand and connects it to a
cluster with auth: the [Docker Compose guide](/docs/manual/docker-compose.md)'s project, or
services running on the host. `scribe-ctl setup --runtime docker --web` does
the same for a cluster it runs ([tutorial 5](/docs/tutorials/05-start-web-interface.md),
[what it wires up](/docs/web-interface.md#run-it-with-scribe-ctl)).

What it needs from the cluster:

| From the cluster | Why |
| --- | --- |
| The storage or gateway endpoint, and the storage and gateway CA (`pki/ca/ca.pem`) | Every request reaches repositories there, verified against that CA |
| The auth endpoint, and the auth CA (`pki/auth-ca/ca.pem`) | Browser sign-in goes to Scribe auth; the web interface has no accounts of its own |
| A reader credential for the `scribe-web` service account | The worker and the asset services index repositories as this account |

It needs Docker Compose v2 and Node.js 26 or newer for the initialization
script. Run every command from the checkout's `apps/web` directory. Compose
builds the images from it, so the checkout must be the cluster's release.

## 1. Create the local files

```sh
node deploy/init.mjs
```

```text
Local secrets are ready. Configure /path/to/scribe/apps/web/.env, then run docker compose up --build -d.
```

It creates, once and never replacing an existing file, the ignored
`deploy/local/` directory: `secrets/jwt.key` and `secrets/surreal.password`
(random, private), empty `trust/`, `tls/` and `worker/` directories, and
`.env` from `deploy/.env.example`. It never copies a CA key or a credential.

## 2. Trust both CAs

Copy the two public CA certificates:

```sh
cp /srv/scribe/pki/ca/ca.pem deploy/local/trust/ca.pem
cp /srv/scribe/pki/auth-ca/ca.pem deploy/local/trust/auth-ca.pem
```

The services see them read-only under `/etc/scribe/trust`. The web services
use Node's TLS, which follows no CA handover: when a CA is rotated, give them
both the old and the new CA for the overlap
([certificates](/docs/manual/certificates.md#rotate-a-server-ca-with-a-handover)).

## 3. Issue the reader credential

Create the `scribe-web` service account, let it read every repository of each
namespace the interface serves (including later ones), and issue its read-only
credential. The credential records the auth address and CA the web services
use, so name auth as they reach it. With the [Docker Compose
guide](/docs/manual/docker-compose.md)'s project, that is the service name, which its
administrator credential already carries; from `/srv/scribe`:

```sh
docker compose run --rm auth-admin users add scribe-web --service
docker compose run --rm auth-admin namespace-readers set scribe-web studio
docker compose run --rm auth-admin credentials create scribe-web --service --name web-reader --days 365 --out /new/reader.credential
mv secrets/new/reader.credential /path/to/scribe/apps/web/deploy/local/worker/reader.credential
```

For a cluster on the host, run the same three commands with `scribe-auth`
([the `auth` helper](/docs/manual/bare-metal.md#administer-auth)), naming the address
containers reach the host at:

```sh
auth users add scribe-web --service
auth namespace-readers set scribe-web studio
auth credentials create scribe-web --service --name web-reader --days 365 \
  --auth tls://host.docker.internal:7443 --auth-ca deploy/local/trust/auth-ca.pem \
  --out deploy/local/worker/reader.credential
```

The account has no password, never signs in and never administers; a
namespace reader grant never writes ([service accounts](/docs/auth.md#service-accounts)).
To limit it to chosen repositories instead, skip `namespace-readers set` and
grant it `reader` on each with `grants set scribe-web NAMESPACE/REPOSITORY_ID
--role reader`.

On Linux the web services run as uid 1000 and see the host files' owner and
mode. If your account is not uid 1000 (`id -u`), let that uid read the
credential, the secrets and the CAs without making anything group- or
world-readable, for example with ACLs (package `acl`):

```sh
sudo setfacl -m u:1000:x deploy/local/worker deploy/local/secrets deploy/local/trust
sudo setfacl -m u:1000:r deploy/local/worker/reader.credential deploy/local/secrets/* deploy/local/trust/*
```

or hand the files to uid 1000 (`sudo chown 1000 ...`, keeping their modes).
`ls -l` then shows `-rw-r-----+`: the group bits display the ACL mask. Docker
Desktop on macOS and Windows maps ownership, and neither step is needed. Never
`chmod 644` a credential file: it holds a private key.

## 4. Point `.env` at the cluster

Edit `.env`. For the Docker Compose guide's project (named `scribe`), the web
services join its network and reach the services by name, which their
certificates carry:

```sh
WEB_SCRIBE_NETWORK=scribe_default
WEB_SCRIBE_SERVER=tls://gateway:7557
WEB_SCRIBE_AUTH_SERVER=tls://auth:7443
WEB_SCRIBE_TLS_SERVER_NAME=
WEB_SCRIBE_AUTH_TLS_SERVER_NAME=
WEB_SCRIBE_CLONE_SERVER=tls://127.0.0.1:7557
WEB_SCRIBE_CLONE_AUTH_SERVER=tls://127.0.0.1:7443
WEB_SCRIBE_READER_CREDENTIAL_FILE=/etc/scribe/worker/reader.credential
```

For services on the host, keep the example's `host.docker.internal`
endpoints: the containers connect there while verifying the certificate
identity `127.0.0.1` (`WEB_SCRIBE_TLS_SERVER_NAME`,
`WEB_SCRIBE_AUTH_TLS_SERVER_NAME`), and set only
`WEB_SCRIBE_READER_CREDENTIAL_FILE`. That is validated on Docker Desktop for
macOS. Native Linux Docker reaches the host's bridge address, not services
bound only to host loopback: there, give Scribe a listener the container
network can reach, or run it in Compose as above. Never disable TLS
verification to fix routing.

`WEB_SCRIBE_CLONE_*` are the addresses the Clone menu shows people.
[Connection configuration](/docs/web-interface.md#connection-configuration)
describes every variable.

## 5. Prepare the database

A new database is prepared, and every release that requires it migrated, by
the worker image's `migrate.js`; no process migrates on startup, and one that
needs a migration refuses to start. Include `compose.scribe.yaml` whenever the
services join the cluster's network:

```sh
docker compose -f compose.yaml -f compose.scribe.yaml build
docker compose -f compose.yaml -f compose.scribe.yaml run --rm worker node apps/worker/dist/migrate.js --dry-run
docker compose -f compose.yaml -f compose.scribe.yaml run --rm worker node apps/worker/dist/migrate.js
```

```text
...
{"event":"applied","id":"0001-stamp-shape-versions","counts":{}}
{"event":"applied","id":"0002-sign-out-bearer-sessions","counts":{}}
{"event":"done","dryRun":false,"migrations":["0001-stamp-shape-versions","0002-sign-out-bearer-sessions"]}
```

`run` starts the database, cache and object store it needs. The flags and the
migration ledger are described in [stored data
versions](/docs/web-interface.md#stored-data-versions).

## 6. Start it

```sh
docker compose -f compose.yaml -f compose.scribe.yaml up -d --wait
curl --fail --show-error http://localhost:8080/api/v1/health
```

```text
{"status":"ok","version":"v1"}
```

Open [http://localhost:8080](http://localhost:8080) and sign in
with a Scribe user: [tutorial 5, step 4](/docs/tutorials/05-start-web-interface.md#4-sign-in-and-browse)
shows what to check, and [its diagnosis table](/docs/tutorials/05-start-web-interface.md#5-diagnose)
applies here with `docker compose logs SERVICE`. Only nginx publishes a port,
on `127.0.0.1` by default ([networks](/docs/web-interface.md#networks)).

## 7. Serve HTTPS with your certificate

The web interface's certificate is yours: one browsers trust for the name
people use, not one from Scribe's private CAs. Put the certificate chain and its
key in `deploy/local/tls/`:

```sh
cp /path/to/fullchain.pem deploy/local/tls/fullchain.pem
cp /path/to/privkey.pem deploy/local/tls/key.pem
```

and set, in `.env`, the exact origin and ports:

```sh
WEB_TLS=1
APP_ORIGIN=https://scribe.example.com
WEB_BIND=0.0.0.0
WEB_PORT=443
WEB_HTTP_PORT=80
```

Run the same `up` again. nginx serves HTTPS on `WEB_PORT` and redirects HTTP on
`WEB_HTTP_PORT` to `APP_ORIGIN`; missing certificate files stop it rather than
falling back to HTTP. Replace the files and run `up` again to renew.

For an intranet name that exists in public DNS but is not reachable from the
internet, `deploy/issue-tls.sh` obtains a Let's Encrypt certificate through
Cloudflare DNS-01 and writes both files; rerun it to renew
(`deploy/issue-tls.sh --help` lists its options):

```sh
# Token with Zone/DNS/Edit and Zone/Zone/Read, kept in an ignored file:
# printf '%s\n' "$token" > deploy/local/secrets/cloudflare.dns.token
# chmod 600 deploy/local/secrets/cloudflare.dns.token
deploy/issue-tls.sh --email ops@example.com scribe.example.com
```

## Afterwards

- **Stop and start:** `docker compose -f compose.yaml -f compose.scribe.yaml
  stop` and `up -d --wait`. The database, search index and object store are
  Compose volumes (`database`, `search`, `objects`); never `down -v`.
- **Backups:** the `database` and `objects` volumes, taken together while the
  services are stopped ([the commands](/docs/manual/upgrades.md#web-interface)), and
  `deploy/local/secrets/` with them; `objects` holds flow-run bundles that
  exist nowhere else, and the search index is rebuilt
  ([what to back up](/docs/migration-backup-restore.md#what-to-back-up)).
- **Reader credential renewal:** [certificates and
  credentials](/docs/manual/certificates.md#service-account-and-web-reader-credentials);
  the services reread it on every use.
- **Upgrades** migrate the database: [upgrades](/docs/manual/upgrades.md#web-interface).
- **Asset previews:** the two sandbox containers need user namespaces; see
  [sandbox containers](/docs/web-interface.md#sandbox-containers) on Ubuntu 23.10
  and later.
