Self-hosting

Run the Spanlens proxy, API, and dashboard on your own infra. Keeps all request bodies, traces, and encrypted provider keys inside your network.

Who should self-host

  • Compliance requirements (SOC 2, HIPAA, data residency) forbid sending LLM bodies through a third-party SaaS
  • You already run Supabase in-house
  • You expect traffic volumes where per-request pricing on the hosted plan exceeds the cost of running your own infra

What you need

  1. A Supabase project. The free tier on supabase.com is enough to start. Plain Postgres is not supported, the server uses @supabase/supabase-js directly. Everything Spanlens stores lives in this one database, request logs included.
  2. A 32-byte encryption key. Used for AES-256-GCM encryption of provider keys at rest. Generate with openssl rand -base64 32. Back this up. Losing it makes every stored provider key unrecoverable.
  3. Docker, or anywhere that can run a Node 22 container (Fly.io, Railway, ECS, Cloud Run, plain VPS).
  4. A reverse proxy with HTTPS in front (Caddy, nginx, Cloudflare Tunnel). The containers speak HTTP on ports 3000 (web) and 3001 (server).

Walkthrough

Option A, docker-compose (recommended)

The easiest way to self-host. Pulls pre-built images from GHCR and runs both the dashboard (web) and the proxy / API server together. No source code needed.

Reverse proxyCaddy / nginxdocker-composeapps/webNext.js · :3000apps/serverHono · :3001Supabaseauth + dataBrowserHTTPS30003001One docker compose up brings up both containers.
Self-host topology: web + server containers behind your reverse proxy, with Supabase Postgres as the external data store.

1. Apply the database schema

Open your Supabase project → SQL Editor → New query, paste the contents of supabase/init.sql, and click Run. No CLI needed. It creates every table the stack uses, the requests log included.

Prefer the terminal? Use psql instead:

bash
curl -o init.sql https://raw.githubusercontent.com/spanlens/Spanlens/main/supabase/init.sql
psql "postgresql://postgres:<password>@db.<ref>.supabase.co:5432/postgres" -f init.sql

2. Create a .env file

bash
# Required
NEXT_PUBLIC_SUPABASE_URL=https://<ref>.supabase.co
NEXT_PUBLIC_SUPABASE_ANON_KEY=eyJ...
SUPABASE_URL=https://<ref>.supabase.co
SUPABASE_ANON_KEY=eyJ...
SUPABASE_SERVICE_ROLE_KEY=eyJ...          # keep server-side only
ENCRYPTION_KEY=$(openssl rand -base64 32) # back this up, see below
CRON_SECRET=$(openssl rand -hex 16)

# Pooled Postgres connection, used only for the requests log.
# Connect > Direct > Transaction pooler. Port 6543, not 5432. Copy the host
# from that dialog: the shared pooler hostname carries a numbered prefix
# that the region does not tell you. It is a full database credential, so
# never log it.
SUPABASE_DB_POOLER_URL=postgresql://postgres.<ref>:<password>@<pooler-host>:6543/postgres

# Optional, for invite emails
# WEB_URL=https://your-domain.com
# RESEND_API_KEY=re_...
# RESEND_FROM=Spanlens <no-reply@your-domain.com>

3. Verify your env file before starting

Bad ENCRYPTION_KEYlength silently corrupts provider keys at decrypt time (the failure surfaces hours later as “wrong API key” from the upstream provider). A missing WEB_URL sends invite emails pointing at localhost. Run check:env once before you start the stack and get an actionable report instead of debugging at runtime:

bash
# From a clone of the repo:
pnpm install
pnpm check:env

# Or one-shot via npx, no clone:
npx -y tsx https://raw.githubusercontent.com/spanlens/Spanlens/main/apps/server/scripts/check-env.ts

Exit 0 means every required variable is present and valid, and that both the Supabase HTTP API and the Postgres pooler answered. Exit 1 means something is wrong, with the exact fix command in the output. --json for CI pipelines, --quiet to show only warnings and errors.

4. Start

bash
curl -o docker-compose.yml https://raw.githubusercontent.com/spanlens/Spanlens/main/docker-compose.yml
docker compose up -d
  • Dashboard: http://localhost:3000
  • API / proxy: http://localhost:3001

Two containers come up, web and server. There is no database container. Postgres is your Supabase project, reached over the network, which is also why the compose file declares no volumes. The web container waits for the server's healthcheck, then reads NEXT_PUBLIC_* from env at startup and patches them into the pre-built bundle, so no rebuild is needed.

Option B, server only

If you run the dashboard separately (at spanlens.io or your own Next.js deployment), you can run just the API server.

1. Create a Supabase project

Sign in at supabase.com, create a project, wait for it to provision (~1 minute). From Project Settings → API, copy:

  • Project URLSUPABASE_URL
  • anon public keySUPABASE_ANON_KEY
  • service_role secret keySUPABASE_SERVICE_ROLE_KEY (server-side only)

Then open Project Settings → Database → Connection string and copy the Transaction pooler string (port 6543) into SUPABASE_DB_POOLER_URL. The server reads the request log over that connection.

2. Apply the schema

Same as Option A step 1, open SQL Editor → New query, paste init.sql, run.

3. Run the server

bash
docker run -d --name spanlens-server \
  -p 3001:3001 \
  -e SUPABASE_URL="https://<ref>.supabase.co" \
  -e SUPABASE_ANON_KEY="eyJ..." \
  -e SUPABASE_SERVICE_ROLE_KEY="eyJ..." \
  -e SUPABASE_DB_POOLER_URL="postgresql://postgres.<ref>:<password>@<pooler-host>:6543/postgres" \
  -e ENCRYPTION_KEY="$(openssl rand -base64 32)" \
  -e CRON_SECRET="$(openssl rand -hex 16)" \
  ghcr.io/spanlens/spanlens-server:latest
bash
curl http://localhost:3001/health
# {"status":"ok"}

4. Point your SDK at the self-hosted proxy

Option 1, the CLI wizard (automates the step below):

bash
npx @spanlens/cli@latest init --server-url https://spanlens.yourcompany.com

Validates your key against your server, patches existing new OpenAI() / new Anthropic() calls, and writes SPANLENS_BASE_URL to .env.local automatically.

Option 2, by hand:

ts
import { createOpenAI } from '@spanlens/sdk/openai'

const openai = createOpenAI({
  baseURL: 'https://spanlens.yourcompany.com/proxy/openai/v1',
})

Environment variables

VariableRequiredDescription
SUPABASE_URLYesYour Supabase project URL (https://<ref>.supabase.co)
SUPABASE_SERVICE_ROLE_KEYYesService role key, used by the server to write to Supabase past RLS (orgs, projects, traces, etc.)
SUPABASE_ANON_KEYYesAnon key, used for RLS-protected reads from dashboard queries
SUPABASE_DB_POOLER_URLYesPooled connection string for the requests table. Use the transaction pooler on port 6543, not the direct port 5432. Session mode pins one backend per client, and a horizontally scaled server runs out of those quickly. Treat the string as a full database credential. See picking the right pooler string if it will not connect.
ENCRYPTION_KEYYes32-byte base64 key for AES-256-GCM provider-key encryption at rest
NEXT_PUBLIC_SUPABASE_URLYes (web only)Same as SUPABASE_URL, exposed to the browser for Supabase Auth
NEXT_PUBLIC_SUPABASE_ANON_KEYYes (web only)Same as SUPABASE_ANON_KEY, exposed to the browser for Supabase Auth
WEB_URLYes (multi-user)Base URL of your dashboard (e.g. https://spanlens.example.com). Used to build the accept link in invitation emails. Falls back to http://localhost:3000 if unset, fine for local dev, broken in production.
PG_POOL_MAXNoConnections held per server instance, default 2. Raising it multiplies across instances instead of adding throughput, so leave it alone unless you run one server of a fixed size.
PG_STATEMENT_TIMEOUT_MSNoServer-side statement timeout, default 60000. Caps a runaway dashboard query so it cannot starve the proxy's auth path, which shares the database.
RESEND_API_KEYNoResend API token for outbound email (invitations). When unset, emails are skipped silently and the invite endpoint returns the accept link as devAcceptUrl so an admin can hand-deliver it.
RESEND_FROMNoSender header. Default Spanlens <notifications@spanlens.io>. Override with a verified sender on your own domain to avoid spam filters.
PORTNoHTTP port for the server (default 3001)

Picking the right pooler string

Supabase offers three connection strings and only one of them suits a serverless or containerised deployment. The Connect dialog shows the others first, so this is worth getting right before you debug anything else.

Open Connect, choose Direct, select Transaction pooler, and switch on Use IPv4 connection. The result looks like this:

bash
postgresql://postgres.<project-ref>:<password>@aws-<n>-<region>.pooler.supabase.com:6543/postgres

Two details are easy to lose. The dedicated pooler that the dialog shows by default resolves to an AAAA record and nothing else, so any host without outbound IPv6 fails at DNS. Vercel functions are in that category. And the numbered prefix on the shared pooler hostname is not derived from the region, so it has to be copied rather than guessed.

The username and the host also travel together: the shared pooler expects postgres.<project-ref>, while the dedicated one expects a bare postgres. Taking one from each is rejected on sight.

When it does not connect, GET /health/deep reports postgresPool.latencyMs, and that number identifies the cause on its own. A rejection that takes hundreds of milliseconds reached the database and was turned away, so the problem is the credential or the tenant. One that returns in a few milliseconds never left the machine, so the problem is the hostname.

LatencyMeaning
Under ~20msDNS did not resolve. Either the hostname is mistyped, or it is the IPv6-only dedicated pooler on a host without IPv6.
~10ms, with a tenant errorHost is right but the username lost its .<project-ref> suffix, so the pooler cannot tell which project you want.
~500ms, tenant not foundWrong numbered prefix in the hostname. The request arrived at a real pooler that has no such tenant.
~500ms, password authentication failedHost and username are right; the password is wrong or was rotated.
Steady, matching your region round tripWorking.

On a managed platform, remember that changing an environment variable does not affect deployments already running. Redeploy, then re-check.

Upgrading

bash
# Pull the latest images and restart
docker compose pull && docker compose up -d

# If a new release added migrations, re-run init.sql in SQL Editor
# (all statements use CREATE IF NOT EXISTS / ALTER IF NOT EXISTS, safe to re-run)

We ship semver tags (ghcr.io/spanlens/spanlens-server:0.3.0, ghcr.io/spanlens/spanlens-web:0.3.0). Pin a tag in production and upgrade deliberately.

Supported architectures. Both images are published as multi-arch manifests for linux/amd64 and linux/arm64, so Docker pulls the right variant for your host automatically. M1 / M2 / M3 Macs and AWS Graviton instances run the native ARM binary; x86 hosts run the amd64 binary. No platform flag needed.

Upgrading a stack that ran a ClickHouse container

Deployments pulled before August 2026 ran a third container and kept the request log inside it. Current releases keep requests in the same Postgres database as everything else. To move an existing stack across:

  1. Re-run init.sql so the requests table exists in Postgres.
  2. Drop CLICKHOUSE_URL, CLICKHOUSE_USER, CLICKHOUSE_PASSWORD, and CLICKHOUSE_DB from your .env, and add SUPABASE_DB_POOLER_URL.
  3. Fetch the current docker-compose.yml and run docker compose up -d --remove-orphans. Nothing references the old container any more, and that flag is what actually stops it.
  4. Old rows are not copied for you. If you want the history, export it from ClickHouse and insert it into requests before you delete the container and its volumes. New calls land in Postgres from the moment the server restarts.

Dashboard options

  • docker-compose (recommended), pulls ghcr.io/spanlens/spanlens-web:latest alongside the server. Full self-hosting with no source required. See Option A above.
  • Use the hosted dashboard at spanlens.io pointed at your self-hosted backend. Log in, then override the API base URL in Settings.
  • Build from source, clone the repo and docker compose up -d --build to build both images locally.

Backups

One database and one secret. Everything Spanlens writes, from organizations and encrypted provider keys down to the last logged token count, is in your Supabase project, so a single pg_dump covers all of it.

  • Supabase Postgres. Managed projects take their own daily backups (Supabase Pro keeps 7 days of them). Add your own logical dumps on top so you hold a copy outside the provider. The commands are on the backup and restore page.
  • ENCRYPTION_KEY, the one thing that lives outside every database. Keep it in your secret manager (AWS Secrets Manager, GCP Secret Manager, HashiCorp Vault). Without it the encrypted provider keys inside a dump are just noise.

Known limitations

  • Plain Postgres isn't supported. The server imports @supabase/supabase-js directly. Moving to a thin abstraction layer is on the roadmap but not a launch blocker.
  • The pooled connection is not optional. Request-log reads go through SUPABASE_DB_POOLER_URL rather than PostgREST, because PostgREST cannot express percentiles, FILTER clauses, or a cursor for large exports. Leave the variable unset and the analytics pages fail.
  • Partitions need a nudge each month. requests is partitioned by month. Call SELECT ensure_requests_partitions(3); on a schedule so the next few months always exist. Nothing breaks if you forget, since rows land in the requests_default catch-all partition, but moving them back out later is a chore one cron line would have saved you.
  • Operational tooling is minimal. No built-in monitoring, no migration rollback tool, no backup cron. DIY for now.

Found a problem? Open an issue on GitHub.