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
- Tectonic / pdfLaTeX / XeLaTeX HTTP worker with latexmk, biber, and bundled TexLab diagnostics.
- →Backup worker
- handles manual and nightly project backups.
- →PostgreSQL
- application database (projects, files, users, citations, comments).
- →MinIO
- S3-compatible object storage for project files, realtime document snapshots, and compile artifacts.
Quick start
Clone the repository, install dependencies, and generate the local environment files:
bun install
bun run env:initThe initializer generates the database password, MinIO credentials, and distinct application secrets. It leaves optional provider credentials blank. Fill the integrations you use in the root .env, then rerun bun run env:init to synchronize every service environment. Existing values are never rotated by a normal rerun.
Start the infrastructure services:
docker compose up -dApply migrations and start the web app in dev mode:
bun run db:migrate
bun run devThe 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 -dService 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- →MinIO API
http://localhost:9000- →MinIO 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_URLto your public HTTPS origin. OAuth callbacks and CSRF protection are derived from this value. - Set
S3_BROWSER_ENDPOINTto the MinIO URL reachable from the browser — usually a public hostname behind your reverse proxy. - 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; the Tectonic bundle is installed in the image. - Run
bun run env:initto generate distinct, high-entropy values forBETTER_AUTH_SECRET,COLLAB_JWT_SECRET,REALTIME_CONTROL_SECRET,BACKUP_ENCRYPTION_SECRET, andAI_CREDENTIALS_SECRET. Existing values are preserved. - Set
WEB_PUBLIC_HOMEPAGE_ENABLED=falseto 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, S3 lifecycle rules). Citet does not prune old snapshots automatically.
- Keep the pinned
TEXLAB_SHA256value when overriding the TexLab version or target. Diagnostics execute in the read-only LaTeX worker sandbox.
Database migrations
Migrations live in packages/db and run with bun 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 bun 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 MINIO_ROOT_USER>
S3_SECRET_KEY=<same value as MINIO_ROOT_PASSWORD>
S3_FORCE_PATH_STYLE=true
S3_BROWSER_ENDPOINT=http://localhost:9000 # URL the browser uses to fetch PDFs
# Services
HOCUSPOCUS_WS_URL=ws://localhost:1234
REALTIME_CONTROL_URL=http://localhost:1234
LATEX_WORKER_URL=http://localhost:8081
PDF_URL_TTL_SECONDS=900 # signed PDF URL lifetime
# Features
WEB_PUBLIC_HOMEPAGE_ENABLED=true # set false to skip marketing pages
# Optional: dedicated Google Drive credentials (takes priority over GOOGLE_*)
GOOGLE_DRIVE_CLIENT_ID=
GOOGLE_DRIVE_CLIENT_SECRET=
# Optional: Zotero citation integration
ZOTERO_CLIENT_KEY=
ZOTERO_CLIENT_SECRET=
# Stored backup provider credential encryption (not archive encryption)
BACKUP_ENCRYPTION_SECRET=<32+ random bytes>
# Optional: AI provider credential encryption and server fallback
AI_CREDENTIALS_SECRET=<32+ random bytes>
OPENAI_API_KEY=
CITET_AI_MODEL=gpt-5.4-miniLaTeX worker (services/latex-worker/.env)
PORT=8081
# Storage
S3_ENDPOINT=http://localhost:9000
S3_REGION=us-east-1
S3_ACCESS_KEY=<same value as MINIO_ROOT_USER>
S3_SECRET_KEY=<same value as MINIO_ROOT_PASSWORD>
S3_ARTIFACT_BUCKET=citet-artifacts
S3_FORCE_PATH_STYLE=true
# Tectonic (bundle installed in the worker image)
CITET_TECTONIC_BUNDLE_PATH=/opt/tectonic/default-bundle
CITET_LATEX_WORKDIR_ROOT=/tmp/citet-latex-worker
TECTONIC_UNTRUSTED_MODE=1
TECTONIC_CONTINUE_ON_ERRORS=true
LATEX_COMMAND_TIMEOUT_MS=120000
LATEX_MAX_OUTPUT_BYTES=1048576
# Build-time pins belong in the root .env (Compose image build arguments)
TEXLAB_VERSION=5.26.0
TEXLAB_TARGET=x86_64-linux
TEXLAB_SHA256=8697bd5e479d4584b14b7eed5c320c80ec4e1d91ebefbb6801e6bf38e9971300Resource 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 descriptorsRealtime 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 MINIO_ROOT_USER>
S3_SECRET_KEY=<same value as MINIO_ROOT_PASSWORD>
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=5Backup 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 MINIO_ROOT_USER>
S3_SECRET_KEY=<same value as MINIO_ROOT_PASSWORD>
S3_DOC_BUCKET=citet-docs
S3_ARTIFACT_BUCKET=citet-artifacts
S3_FORCE_PATH_STYLE=true
# Provider credential encryption — same value as web app (ZIP archives are unencrypted)
BACKUP_ENCRYPTION_SECRET=<32+ random bytes>
BACKUP_POLL_INTERVAL_MS=10000 # how often to check for pending backup jobs
# Optional deployment-owned S3 destination
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=Backup ZIP restores are validated and imported into a new project; they never overwrite 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/oauth2/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. SAML and automatic university membership are not implemented.
Chalmers publishes SAML SSO through SWAMID. Connecting a SAML university requires a SAML-to-OIDC broker and federation registration or an agreement with the university. A student account alone does not provide an application client registration.
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 bun 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 integration setup
The Zotero integration is hidden unless the server is configured with credentials.
- Register an OAuth application on the Zotero developer portal.
- Set the callback URL to
https://<your-host>/api/auth/callback/zotero. - Add to the root
.env, then runbun run env:init:ZOTERO_CLIENT_KEY=... ZOTERO_CLIENT_SECRET=... # BETTER_AUTH_URL must be set to your public origin - Run
bun run db:migrate.
Users connect their Zotero library from Project settings → Citations. See Citations for the user-facing flow.
Google Drive backup setup
Drive backups require a Google OAuth web application with the Drive API scope enabled.
- Create an OAuth 2.0 web application in the Google Cloud Console with the
https://www.googleapis.com/auth/drive.filescope. - Add the callback URL:
https://<your-host>/api/backups/google-drive/callback - Set credentials in the root
.env(dedicated Drive credentials are preferred; the worker falls back toGOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET), then runbun run env:init:GOOGLE_DRIVE_CLIENT_ID=... GOOGLE_DRIVE_CLIENT_SECRET=... - The initializer generates one
BACKUP_ENCRYPTION_SECRETand synchronizes it to the web app and backup worker. - Apply migrations and start the backup worker:
bun run db:migrate
docker compose up -d --build backup-workerUsers connect Drive from Project settings → Backups. 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;
"
# MinIO
docker run --rm --network citet_default minio/mc alias set local \
http://citet-minio:9000 "${MINIO_ROOT_USER:?required}" "${MINIO_ROOT_PASSWORD:?required}"
docker run --rm --network citet_default minio/mc rm --recursive --force local/citet-docs/projects
docker run --rm --network citet_default minio/mc rm --recursive --force local/citet-artifactsDo not run this against a production instance. There is no confirmation prompt.