Operations

Self-hosting

Citet ships as a Docker Compose stack. A small department or research group can run the full system on a single VM. Larger installs typically give the database and the LaTeX worker their own hosts.

Stack overview

→Web app
TanStack Start + React. Serves the browser editor, all API server functions, and the marketing pages.
→Realtime (Hocuspocus)
Yjs WebSocket server for real-time collaborative editing.
→Compile worker
job queue processor and gateway to the LaTeX worker.
→LaTeX worker
sandboxed pdfLaTeX / XeLaTeX / LuaLaTeX / Shipout HTTP worker using texe and the Paper’s lock. Language assistance runs locally in the browser.
→Backup worker
encrypted project backups to Google Drive (copies of the stored, still encrypted data; the worker holds nothing that decrypts it), project purge, cleanup of orphaned build artifacts and the security monitor.
→PostgreSQL
application database (projects, files, users, citations, comments).
→RustFS
S3-compatible object storage for project files, realtime document snapshots, and compile artifacts. RustFS is the default; any S3-compatible store that accepts path-style requests works if you point the S3_* variables at it and create the citet-docs and citet-artifacts buckets.

Quick start

Clone the repository, install dependencies, and generate the local environment files:

vp install
vp run env:init

The initializer generates the database password, RustFS credentials, and distinct application secrets. It leaves optional provider credentials blank. Fill the integrations you use in the root .env, then rerun vp run env:init to synchronize every service environment. Existing values are never rotated by a normal rerun.

Start the infrastructure services:

docker compose up -d

Apply migrations and start the web app in dev mode:

vp run db:migrate
vp run dev

The web app listens on http://localhost:3000. To run the web app inside Docker as well, use the app profile:

docker compose --profile app up -d

Service ports

→Web app
http://localhost:3000
→Realtime (Hocuspocus)
ws://localhost:1234
→LaTeX worker
http://localhost:8081
→Backup worker
http://localhost:8082/health
→PostgreSQL
localhost:5432
→RustFS S3 API
http://localhost:9000
→RustFS Console
http://localhost:9001

Production checklist

  • Put the web app behind a TLS-terminating reverse proxy (Caddy, Traefik, or nginx) and forward WebSocket upgrades to the Hocuspocus service.
  • Set BETTER_AUTH_URL to your public HTTPS origin. OAuth callbacks and CSRF protection are derived from this value.
  • Rebuild the LaTeX image when changing its pinned TeX Live image or package bundle, and update LATEX_TOOLCHAIN_VERSION. Build work directories and caches are temporary; TeX Live is installed in the image.
  • Run vp run env:init to generate distinct, high-entropy values for BETTER_AUTH_SECRET, COLLAB_JWT_SECRET, REALTIME_CONTROL_SECRET, BACKUP_ENCRYPTION_SECRET, and BUILD_WORKER_SECRET, and derives the build service key from the last. Existing values are preserved.
  • Set WEB_PUBLIC_HOMEPAGE_ENABLED=false to disable the public marketing pages. The root route then redirects straight to login or the projects page.
  • Configure your backup retention policy externally (Drive quotas). Citet never deletes backups from Drive; a scheduled backup of an unchanged project uploads nothing.
  • The web build includes pinned Badness assets and their licenses. Language analysis requires no LaTeX worker request.

Database migrations

Migrations live in packages/db and run with vp run db:migrate. Run this after every upgrade. The command is idempotent — already-applied migrations are skipped automatically.

Environment variable reference

Edit optional integrations in the root .env and rerun vp run env:init. The generated service files should not be maintained separately.

Web app (apps/web/.env)

# Database
DATABASE_URL=<generated local PostgreSQL URL>

# Authentication
BETTER_AUTH_SECRET=<32+ random chars>
BETTER_AUTH_URL=http://localhost:3000   # your public origin
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
CITET_OIDC_PROVIDERS=[]                # configured university OIDC providers

# Optional invitation email
SMTP_HOST=
SMTP_PORT=587
SMTP_SECURE=false
SMTP_REQUIRE_TLS=true
SMTP_USER=
SMTP_PASS=
SMTP_FROM=

# Collaboration (shared with Hocuspocus)
COLLAB_JWT_SECRET=<32+ random chars>
REALTIME_CONTROL_SECRET=<another 32+ random chars>
COLLAB_TOKEN_TTL_SECONDS=300           # WebSocket token lifetime (seconds)

# Storage
S3_ENDPOINT=http://localhost:9000
S3_REGION=us-east-1
S3_ACCESS_KEY=<same value as the root .env>
S3_SECRET_KEY=<same value as the root .env>
S3_FORCE_PATH_STYLE=true

# Services
HOCUSPOCUS_WS_URL=ws://localhost:1234
REALTIME_CONTROL_URL=http://localhost:1234
LATEX_WORKER_URL=http://localhost:8081

# Features
WEB_PUBLIC_HOMEPAGE_ENABLED=true       # set false to skip marketing pages

# Build service key, built into the bundle (written by env:init)
VITE_CITET_BUILD_WORKER_PUBLIC_KEY=<derived from BUILD_WORKER_SECRET>

# Optional: dedicated Google Drive credentials (takes priority over GOOGLE_*)
GOOGLE_DRIVE_CLIENT_ID=
GOOGLE_DRIVE_CLIENT_SECRET=

# Stored backup provider credential encryption (not archive encryption)
BACKUP_ENCRYPTION_SECRET=<32+ random bytes>

LaTeX worker (services/latex-worker/.env)

PORT=8081

# Storage
S3_ENDPOINT=http://localhost:9000
S3_REGION=us-east-1
S3_ACCESS_KEY=<same value as the root .env>
S3_SECRET_KEY=<same value as the root .env>
S3_ARTIFACT_BUCKET=citet-artifacts
S3_FORCE_PATH_STYLE=true

CITET_LATEX_WORKDIR_ROOT=/tmp/citet-latex-worker
LATEX_COMMAND_TIMEOUT_MS=120000
LATEX_MAX_OUTPUT_BYTES=1048576

# The build service's account secret (32 bytes, base64url); only this worker holds it
BUILD_WORKER_SECRET=<generated by env:init>

Build service key

Builds decrypt projects only inside the sandboxed LaTeX worker, with a key each client seals to one of the worker's hourly encryption keys, which exist only in its memory and are signed by the worker's public key. Run a single LaTeX worker for encrypted builds. vp run env:init derives that public key from BUILD_WORKER_SECRET and writes it as VITE_CITET_BUILD_WORKER_PUBLIC_KEY; Compose builds it into the web image, and browsers never accept a key from the server instead. An image built without it has server builds disabled. The web image also serves the key as /build-worker.json. VS Code and Neovim (through citet-sync) trust it on their first build when it equals the key the LaTeX worker offers, remember it, and refuse a changed one until the user accepts it in the editor (“Accept New Build Service”).

To rotate it, set a new BUILD_WORKER_SECRET in the root .env, run vp run env:init, restart every LaTeX worker, rebuild the web image (docker compose --profile app build web) and recreate it. Then announce the change and the new code, which vp run env:init prints, to VS Code and Neovim users: their next server build asks them to accept it.

Resource limits (set in the root .env, forwarded by Compose):

LATEX_WORKER_MEMORY_LIMIT=2g          # container memory cap
LATEX_WORKER_CPUS=2                   # CPU shares
LATEX_WORKER_PIDS_LIMIT=256           # max processes
LATEX_WORKER_NOFILE_HARD_LIMIT=2048   # max open file descriptors

Realtime server (services/realtime-hocuspocus)

PORT=1234
HOST=0.0.0.0

# Storage
S3_ENDPOINT=http://localhost:9000
S3_REGION=us-east-1
S3_ACCESS_KEY=<same value as the root .env>
S3_SECRET_KEY=<same value as the root .env>
S3_DOC_BUCKET=citet-docs
S3_FORCE_PATH_STYLE=true
HOCUSPOCUS_S3_PREFIX=hocuspocus-documents/

# Auth — must match web app
COLLAB_JWT_SECRET=<same value as web app>

# Tuning
REALTIME_THROTTLE_CONNECTIONS_PER_MINUTE=60
REALTIME_THROTTLE_BAN_MINUTES=5

Backup worker (services/backup-worker/.env)

PORT=8082
DATABASE_URL=<generated local PostgreSQL URL>

# Storage
S3_ENDPOINT=http://localhost:9000
S3_REGION=us-east-1
S3_ACCESS_KEY=<same value as the root .env>
S3_SECRET_KEY=<same value as the root .env>
S3_DOC_BUCKET=citet-docs
S3_ARTIFACT_BUCKET=citet-artifacts
S3_FORCE_PATH_STYLE=true

# Provider credential encryption — same value as web app
BACKUP_ENCRYPTION_SECRET=<32+ random bytes>

# Deployment-owned S3 destination (only to delete archives of purged projects)
BACKUP_DEPLOYMENT_NAME=
BACKUP_DEPLOYMENT_S3_ENDPOINT=
BACKUP_DEPLOYMENT_S3_REGION=
BACKUP_DEPLOYMENT_S3_BUCKET=
BACKUP_DEPLOYMENT_S3_ACCESS_KEY=
BACKUP_DEPLOYMENT_S3_SECRET_KEY=
BACKUP_DEPLOYMENT_S3_FORCE_PATH_STYLE=false

# Google Drive (required if Drive backups are enabled)
GOOGLE_DRIVE_CLIENT_ID=
GOOGLE_DRIVE_CLIENT_SECRET=

# Hour (UTC) at which nightly backups are queued
BACKUP_NIGHTLY_UTC_HOUR=2

Restores run in the member's browser. A readable backup ZIP becomes a new project; an encrypted backup recreates a deleted project under its original id for its owner (the server checks every entry as it would an upload), and otherwise becomes a new project. Nothing overwrites an existing project. Compile PDFs are retained separately for normal compiles and revision comparisons through COMPILE_ARTIFACT_RETENTION_SUCCESS_COUNT and LATEXDIFF_ARTIFACT_RETENTION_SUCCESS_COUNT.

Sign-in and university SSO

Configure Google credentials, institutional OpenID Connect providers, or both. Google is optional when an OIDC provider is configured. Each provider appears on the login screen in hosted and self-hosted deployments.

CITET_OIDC_PROVIDERS='[{"providerId":"example-university","name":"Example University","issuer":"https://sso.example.edu/realms/university","clientId":"citet","clientSecret":"replace-with-secret"}]'

Register https://your-host/api/auth/callback/example-universitywith the provider. It must support discovery, PKCE, and an authenticated UserInfo endpoint returning sub, email, and a booleanemail_verified claim. Use HTTPS and keep provider IDs and issuers stable.

Existing users connect another provider from Account → Sign-in methodswhile signed in; both accounts must use the same email. Accounts are not linked automatically by email. University affiliation does not automatically grant organization membership or project access.

Chalmers publishes SAML SSO through SWAMID. Citet includes SAML federation support using SWAMID's signed metadata, including universities imported through eduGAIN. No SAML-to-OIDC broker is required. Federation registration and institutional attribute release are required before university accounts can sign in to your installation.

CITET_SAML_ENABLED=true
CITET_SAML_ORGANIZATION_NAME=Your real responsible operator name
CITET_SAML_ORGANIZATION_DISPLAY_NAME=Your service operator
CITET_SAML_CONTACT_EMAIL=operations@your-domain
CITET_SAML_ENTITY_CATEGORIES=https://refeds.org/category/personalized,http://refeds.org/category/research-and-scholarship

Set the public HTTPS address in BETTER_AUTH_URL, then run vp run saml:init and vp run env:init. The initializer preserves existing keys or generates a persistent 4096-bit RSA key and ten-year certificate. Rebuild and recreate the web service with your deployment overrides. Review /saml/metadata, register it with the federation, and use /saml/acs as the POST assertion consumer. Only request entity categories that your service qualifies for; approval belongs to the federation.

Invitation email

Set SMTP_HOST and SMTP_FROM, plus the relay credentials if needed. Port 587 defaults to required STARTTLS; port 465 uses implicit TLS. Synchronize the service environment with vp run env:init. Access is saved before sending, and failed delivery is shown in project settings. Repeat an invitation to retry. Without SMTP, share the project URL directly.

Pending invitations require sign-in with a verified matching email. A provider that omits email_verified cannot redeem these invitations.

Zotero

Browsers call the Zotero Web API at https://api.zotero.org directly, so it must be reachable from users' browsers. Users either paste a read-only API key they created on zotero.org, or choose Sign in with Zotero when the server has a Zotero app registered at zotero.org/oauth/apps (callback https://<your-host>/api/zotero/callback) and ZOTERO_CLIENT_KEY / ZOTERO_CLIENT_SECRET set for the web app. The server only performs the sign-in handshake and hands the resulting key to the user's browser; it stores it nowhere. The browser keeps it end-to-end encrypted in the user's account settings. See Citations for the user-facing flow.

AI assistant

The instance needs no AI configuration. Browsers call each user's provider directly with their own key, so the providers' API hosts (or a university gateway) must be reachable from users' browsers. A ChatGPT subscription goes through citet-sync on the user's own computer instead: it listens on 127.0.0.1 ports 47631-47633 and, during a ChatGPT sign-in, on port 1455, and needs to reach auth.openai.com and chatgpt.com. citet-sync answers only the Citet sites it has a session for, so users open a project from this instance in VS Code or Neovim, or run citet-sync login https://<your-host>, first. There is no server relay to configure. See AI assistant.

Google Drive backup setup

Drive backups require a Google OAuth web application with the Drive API scope enabled.

  1. Create an OAuth 2.0 web application in the Google Cloud Console with the https://www.googleapis.com/auth/drive.file scope.
  2. Add the callback URL: https://<your-host>/api/backups/google-drive/callback
  3. Set credentials in the root .env (dedicated Drive credentials are preferred; the worker falls back to GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET), then run vp run env:init:
    GOOGLE_DRIVE_CLIENT_ID=...
    GOOGLE_DRIVE_CLIENT_SECRET=...
  4. The initializer generates one BACKUP_ENCRYPTION_SECRET and synchronizes it to the web app and backup worker.
  5. Apply migrations and start the backup worker:
vp run db:migrate
docker compose up -d --build backup-worker

Users connect Drive from Account or Project settings → Backups. Browsers upload readable ZIPs to https://www.googleapis.com directly, so it must be reachable from users' browsers. See Backups for the user-facing flow.

Resetting local data

For development environments only — a full wipe of all projects, files, and compile artifacts:

set -a
source .env
set +a

# PostgreSQL
docker exec -i citet-postgres psql -U "${POSTGRES_USER:-citet}" -d "${POSTGRES_DB:-citet}" -c "
TRUNCATE TABLE
  compile_artifacts,
  compile_jobs,
  yjs_documents,
  files,
  project_members,
  projects
RESTART IDENTITY CASCADE;
"

# RustFS
s3() {
  docker run --rm --network citet_default \
    -e AWS_ACCESS_KEY_ID="${S3_ACCESS_KEY:?required}" -e AWS_SECRET_ACCESS_KEY="${S3_SECRET_KEY:?required}" \
    -e AWS_DEFAULT_REGION=us-east-1 amazon/aws-cli --endpoint-url http://citet-rustfs:9000 s3 "$@"
}
s3 rm --recursive s3://citet-docs/projects
s3 rm --recursive s3://citet-artifacts

Do not run this against a production instance. There is no confirmation prompt.