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
- A Supabase project. The free tier on supabase.com is enough to start. Plain Postgres is not supported, the server uses
@supabase/supabase-jsdirectly. Everything Spanlens stores lives in this one database, request logs included. - 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. - Docker, or anywhere that can run a Node 22 container (Fly.io, Railway, ECS, Cloud Run, plain VPS).
- 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.
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:
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.sql2. Create a .env file
# 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:
# 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.tsExit 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
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 URL →
SUPABASE_URL - anon public key →
SUPABASE_ANON_KEY - service_role secret key →
SUPABASE_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
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:latestcurl http://localhost:3001/health
# {"status":"ok"}4. Point your SDK at the self-hosted proxy
Option 1, the CLI wizard (automates the step below):
npx @spanlens/cli@latest init --server-url https://spanlens.yourcompany.comValidates 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:
import { createOpenAI } from '@spanlens/sdk/openai'
const openai = createOpenAI({
baseURL: 'https://spanlens.yourcompany.com/proxy/openai/v1',
})Environment variables
| Variable | Required | Description |
|---|---|---|
SUPABASE_URL | Yes | Your Supabase project URL (https://<ref>.supabase.co) |
SUPABASE_SERVICE_ROLE_KEY | Yes | Service role key, used by the server to write to Supabase past RLS (orgs, projects, traces, etc.) |
SUPABASE_ANON_KEY | Yes | Anon key, used for RLS-protected reads from dashboard queries |
SUPABASE_DB_POOLER_URL | Yes | Pooled 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_KEY | Yes | 32-byte base64 key for AES-256-GCM provider-key encryption at rest |
NEXT_PUBLIC_SUPABASE_URL | Yes (web only) | Same as SUPABASE_URL, exposed to the browser for Supabase Auth |
NEXT_PUBLIC_SUPABASE_ANON_KEY | Yes (web only) | Same as SUPABASE_ANON_KEY, exposed to the browser for Supabase Auth |
WEB_URL | Yes (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_MAX | No | Connections 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_MS | No | Server-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_KEY | No | Resend 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_FROM | No | Sender header. Default Spanlens <notifications@spanlens.io>. Override with a verified sender on your own domain to avoid spam filters. |
PORT | No | HTTP 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:
postgresql://postgres.<project-ref>:<password>@aws-<n>-<region>.pooler.supabase.com:6543/postgresTwo 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.
| Latency | Meaning |
|---|---|
| Under ~20ms | DNS 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 error | Host is right but the username lost its .<project-ref> suffix, so the pooler cannot tell which project you want. |
| ~500ms, tenant not found | Wrong numbered prefix in the hostname. The request arrived at a real pooler that has no such tenant. |
| ~500ms, password authentication failed | Host and username are right; the password is wrong or was rotated. |
| Steady, matching your region round trip | Working. |
On a managed platform, remember that changing an environment variable does not affect deployments already running. Redeploy, then re-check.
Upgrading
# 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:
- Re-run
init.sqlso therequeststable exists in Postgres. - Drop
CLICKHOUSE_URL,CLICKHOUSE_USER,CLICKHOUSE_PASSWORD, andCLICKHOUSE_DBfrom your.env, and addSUPABASE_DB_POOLER_URL. - Fetch the current
docker-compose.ymland rundocker compose up -d --remove-orphans. Nothing references the old container any more, and that flag is what actually stops it. - Old rows are not copied for you. If you want the history, export it from ClickHouse and insert it into
requestsbefore 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:latestalongside 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 --buildto 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-jsdirectly. 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_URLrather than PostgREST, because PostgREST cannot express percentiles,FILTERclauses, or a cursor for large exports. Leave the variable unset and the analytics pages fail. - Partitions need a nudge each month.
requestsis partitioned by month. CallSELECT ensure_requests_partitions(3);on a schedule so the next few months always exist. Nothing breaks if you forget, since rows land in therequests_defaultcatch-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.