Skip to content

Self-hosting

You can run your own backend and point the app at it. The server stores encrypted items and holds no key that opens them, so self-hosting does not change the security model; you see the same metadata any operator does (listed in the security model). This page is a provider-agnostic overview; the full operational runbook, including one worked VPS setup, hardening, and the deploy pipeline, is in the backend repo’s docs/DEPLOY.md.

Four parts. Only the first is code you ship; the other three are services it talks to.

Component Role
FastAPI service The API and the /ws realtime endpoint. Stateless; run one or many replicas.
Redis WebSocket pub/sub fan-out and device presence. Required. Holds only ephemeral state.
Postgres + Auth Ciphertext store and identity. Supabase provides both; the backend verifies its tokens and never signs one.
Blob storage Encrypted image and file blobs, via presigned URLs. Any S3-compatible store (Cloudflare R2, MinIO, AWS S3).

Redis is not optional: realtime and presence depend on it. Text and note sync work without blob storage; only large image and file attachments need it.

Only the FastAPI service is code you ship. Supabase, Redis and blob storage are services it talks to.

Supabase owns identity. Create a project and collect its connection string, project URL, and a server-only secret key. Since 2025-10-01 Supabase signs tokens with asymmetric keys verified via JWKS, so on a new project you leave the legacy JWT secret blank; the backend detects the algorithm per token. The exact env values and the pooler-vs-direct-host detail are in docs/DEPLOY.md.

S3-compatible blob storage. Create a bucket and an API token. The bucket must already exist; the service only presigns URLs, it never creates buckets.

Copy .env.example to .env and fill it in. The main groups:

Terminal window
# Database (Supabase connection string, asyncpg driver)
DATABASE_URL=postgresql+asyncpg://...
# Redis
REDIS_URL=redis://redis:6379/0
# Supabase Auth (verify only)
SUPABASE_URL=https://your-project-ref.supabase.co
SUPABASE_JWT_SECRET= # blank on projects from 2025-10-01 onward
SUPABASE_SERVICE_ROLE_KEY= # server-only secret key; never ship to clients
# Blob storage (S3-compatible)
S3_ENDPOINT_URL=https://...
S3_BUCKET=clipboard-blobs
AWS_ACCESS_KEY_ID=...
AWS_SECRET_ACCESS_KEY=...
AWS_REGION=auto
# App
APP_CORS_ORIGINS=tauri://localhost,http://localhost:1420
DEFAULT_BLOB_QUOTA_BYTES=52428800 # 50 MB per user
ADMIN_API_KEY= # blank disables the admin endpoints

APP_CORS_ORIGINS must include the app’s origin, which is tauri://localhost by default. Leave ADMIN_API_KEY blank unless you need the admin endpoints; blank makes them return 503.

The repo ships a Dockerfile, a dev docker-compose.yml (with local Postgres, Redis, and MinIO), and a docker-compose.prod.yml. For a local stack:

Terminal window
docker-compose up

Then apply migrations:

Terminal window
uv run alembic upgrade head

Check that it is running. A healthy server answers with status 200:

Terminal window
curl http://localhost:8000/internal/healthz

Migrations are never applied automatically by the deploy: a merged migration has not reached the database until someone runs it. Apply them deliberately after deploying new code.

A deploy ships code, not schema. Until you apply the migration, routes that need it fail.

Put a reverse proxy in front of the service for TLS (the sample setup uses Caddy for automatic certificates). Then point the app at your API, as below.

The app needs three values, and they are compiled in, so users never type them:

Value Where to find it
Server URL Your deployed API, for example https://sync.example.com
Supabase project URL Supabase, Settings, API Keys, Project URL
Supabase publishable key Supabase, Settings, API Keys. It starts with sb_publishable_; older projects call it the anon key

Never use the secret key (sb_secret_). It gives full access to your project, and anything compiled into the app can be read by anyone who has a copy.

  1. Set the three constants at the top of src-tauri/src/sync/config.rs in the app:
    const DEFAULT_SERVER_URL: &str = "https://sync.example.com";
    const DEFAULT_SUPABASE_URL: &str = "https://your-project-ref.supabase.co";
    const DEFAULT_SUPABASE_ANON_KEY: &str = "sb_publishable_...";
    These values are safe to commit. If they are empty, the Account & Sync screen says the build has no sync endpoints.
  2. Build or run the app. Sign up on the Account & Sync screen; your account and this device should appear.

To point an installed copy at a different server without rebuilding, set sync_server_url, supabase_url and supabase_anon_key in settings.json in the app’s data folder. There is no screen for this on purpose.

Google sign-in needs all three of these, or it fails after the consent screen with no useful error:

  1. In Supabase, under Authentication, Providers, enable Google with the client ID and secret from Google Cloud Console.
  2. In Google Cloud Console, add https://<project-ref>.supabase.co/auth/v1/callback to the OAuth client’s authorized redirect URIs.
  3. In Supabase, under Authentication, URL Configuration, add all three of http://127.0.0.1:53170, http://127.0.0.1:53171 and http://127.0.0.1:53172 to the redirect URLs.
Each hop needs its own setting. Miss step 3 and the consent screen succeeds, but the app waits until it times out.

Step 3 is the easy one to miss. The app receives the sign-in on the first free port of those three on the user’s computer. If they are not allowed, Google’s consent succeeds but Supabase will not send the user back, and the app waits until it times out after 5 minutes. The ports are fixed in src-tauri/src/sync/oauth.rs.

Migrations in detail, the build-on-box deploy pipeline, rollback, log retention, metrics, and troubleshooting are all in the backend docs/DEPLOY.md. That runbook documents one concrete setup; adapt the host-specific parts to wherever you run it.