Configuration
Every variable the deployment and the Collector read, and what each one is for.
The three things a self-hoster supplies are reached by plain configuration, with no provider in the code path: the database is a Postgres URL, storage is any S3-compatible endpoint, and the public base URL is whatever the deployment answers on. Auth is the exception, and is named as one — it is Supabase Auth.
.env.example in the repository is the same list in copyable form. Real
values live in .env, which is gitignored, or in your deployment's own secret
store.
The server's .env belongs in apps/web
Next.js reads environment files from its own project root, so a .env at the
repository root is read by nothing when you run the app directly, and the
first page that needs one fails with NEXT_PUBLIC_SUPABASE_URL is not set.
Docker Compose is the exception: it reads the root .env.
Server
Database
| Variable | Required | What it is |
|---|---|---|
DATABASE_URL | yes | Everything the dashboard reads. Any Postgres; Supabase is not a requirement |
INGEST_DATABASE_URL | yes | What the ingest route writes as. The owning role, not sessclone_app |
Two variables, because they are two roles. Point DATABASE_URL at
sessclone_app, a role that owns nothing. Postgres applies no row-level
security to a superuser or to the role that owns the tables, so a dashboard
connected as the role that applied the migrations has every policy switched off
and no error to say so. Ingest writes tables sessclone_app has no insert
grant on, so it gets the owning role through its own variable, and the ingest
route is the only code that reads it.
Neither has a default, and neither falls back to the other. A missing one
throws DATABASE_URL is not set (or INGEST_DATABASE_URL is not set) on the
first request that needs it. A silent fallback would make a misconfigured
deployment — both pointed at the owning role, row-level security off — appear to
work.
Supabase (sign-in)
| Variable | Required | What it is |
|---|---|---|
NEXT_PUBLIC_SUPABASE_URL | yes | Project URL. NEXT_PUBLIC_ because the browser needs it |
NEXT_PUBLIC_SUPABASE_ANON_KEY | yes | Anon key. Public by design; row-level security protects the rows, not this key |
SUPABASE_SERVICE_ROLE_KEY | no | Server only. Lets the retention sweep remove a deleted account's sign-in |
SUPABASE_SERVICE_ROLE_KEY has one use: once an account's 14-day deletion
grace ends, the daily retention sweep removes its sign-in through Supabase's
Admin API. Nothing else reads it, and nothing the browser reaches does.
Without it, deletions still scrub every row on time, but the sign-in stays and
the Admin panel lists the account until the key is set. Keep it server-side.
Sign-in is GitHub OAuth and a magic link; no password is created or stored.
Both are configured in the Supabase project: GitHub needs a client id and
secret under Authentication, and both need <NEXT_PUBLIC_APP_URL>/auth/callback
in the project's allowed redirect URLs.
The magic-link email is sent by Supabase and configured in the Supabase project's own SMTP settings. The invitation email and the sign-up note are the app's own, below.
Templates for the Supabase emails, in the app's look, are in the repo's
supabase/templates/; paste them into Authentication > Emails.
Email (SMTP)
| Variable | Required | What it is |
|---|---|---|
SMTP_URL | no | Connection URL, e.g. smtp://user:pass@smtp.example.com:587 or smtps://…:465 |
SMTP_FROM | no | From address on the invitation, e.g. SessClone <no-reply@example.com> |
Both or neither. With them set, inviting someone emails them the join link;
without, the inviter is given the link to pass on themselves — the same link
the email would carry. smtp:// uses STARTTLS when the server offers it;
smtps:// is TLS from the first byte.
The same two variables send the platform admins a note each time somebody signs up and creates an Org waiting for approval, with a link to that Org under /admin → Orgs. A New Org started from the Org switcher sends the same note, the same way — never with approval off or once the Org is active. Unset, nothing is sent; the Admin panel lists waiting Orgs first and counts them on its navigation link either way.
Sign-up approval
| Variable | Required | Default | What it is |
|---|---|---|---|
SIGNUP_APPROVAL | no | on | off lets a new Org in without a platform admin approving it |
On by default, self-hosted deployments included. An Org whose subscription is
inactive — where every sign-up starts, on the plan it picked — or which has
no subscription row, or is cancelled, is locked: the dashboard shows only a
waiting page, the Org switcher and Sign out, no key can be created, and ingest answers its
existing keys with the same 401 as any unknown key. past_due is not locked;
it keeps its notice and works. A platform admin approves an Org by setting it
active under /admin → Orgs, where waiting Orgs are listed first.
A New Org started from the Org switcher waits in the same way, and a person
may have only one Org of their own waiting at a time.
Any value other than off (case-insensitive) leaves it on. With it off, every
status behaves as it did before the lock: a notice on every page, collection
working. Read per request, so a restart is enough to change it.
Public base URL
| Variable | Required | What it is |
|---|---|---|
NEXT_PUBLIC_APP_URL | yes | Origin this deployment answers on, e.g. https://sessclone.example.com. No trailing slash |
Used to build the sign-in redirect, invite links and the install instructions a
Member is shown, so your team is told to report to your deployment. It is not
derived from request headers: a forwarded Host is attacker-controllable, and
an invite link is a credential.
Public pages (optional)
| Variable | Required | What it is |
|---|---|---|
SEARCH_INDEXING | no | on lets search engines crawl the public pages. Anything else blocks every crawler |
NEXT_PUBLIC_CONTACT_EMAIL | no | Address the public pages offer for contact. Unset, contact links are hidden |
NEXT_PUBLIC_CLOUDFLARE_ANALYTICS_TOKEN | no | Cloudflare Web Analytics site token. Cookieless; marketing pages and docs |
NEXT_PUBLIC_CLARITY_PROJECT_ID | no | Microsoft Clarity project id. Marketing pages only, never the dashboard |
A self-hosted copy stays out of search results and loads no analytics unless
you set these. Canonical links, the sitemap and llms.txt are built from
NEXT_PUBLIC_APP_URL. Clarity asks visitors in Europe first and does not load
if they decline. The NEXT_PUBLIC_ variables are read at build time, so a
change needs a redeploy.
The Privacy and Terms pages describe the hosted service at sessclone.com: its providers and its analytics. Replace them with your own before you open a deployment to people outside your team.
Storage
Transcripts go straight to storage through a presigned PUT; the application never carries the bytes. Only the S3 API is used, so Supabase Storage, Cloudflare R2, AWS S3, Oracle Cloud Object Storage and MinIO are the same code path. With these unset, archival is off and everything else works.
| Variable | Required | Default | What it is |
|---|---|---|---|
STORAGE_ENDPOINT | for archival | — | S3 API endpoint URL. The provider's S3 endpoint, not its dashboard |
STORAGE_REGION | no | auto | Region. auto suits R2 and Supabase; AWS needs the real one, e.g. eu-west-2 |
STORAGE_BUCKET | for archival | — | Bucket holding transcripts. Must not be public: reads are presigned |
STORAGE_ACCESS_KEY_ID | for archival | — | Access key id |
STORAGE_SECRET_ACCESS_KEY | for archival | — | Secret access key |
STORAGE_FORCE_PATH_STYLE | no | true | Path-style addressing. Required by MinIO and Supabase; AWS accepts it |
STORAGE_PRESIGN_TTL_SECONDS | no | 300 | Life of an issued URL. A presigned URL is a bearer credential — keep it short |
Self-hosting has the
script that checks a bucket speaks every part of the S3 API this uses, and the
CORS rule the transcript viewer needs. That script reads the same variables,
plus STORAGE_COMPAT_CREATE_BUCKET=true to create the bucket before checking
it.
Public site (optional)
| Variable | Required | Default | What it is |
|---|---|---|---|
ENABLE_LANDING | no | off | true serves the landing page at / and /pricing |
ENABLE_DOCS | no | off | true serves these docs at /docs and their search at /api/search |
ENABLE_DEMO | no | off | true runs the read-only demo at /demo (below) and shows "Try the demo" beside sign-up |
Each is true or false, and unset is off, so a self-hosted copy is the
dashboard alone unless you opt in. Only true (any case) turns one on.
- Landing off:
/sends a signed-out visitor to sign-in and a signed-in one to the dashboard./pricingreturns 404;/privacyand/termsstay, because sign-up and the dashboard's Legal notices link to them. Sign-up keeps its plan step. The dashboard's logo opens Costs. - Landing on: a signed-in person who types
/or follows a link to it from elsewhere gets the dashboard. The dashboard's logo still opens the landing page, whose header then offers Dashboard in place of Sign in. - Docs off:
/docsand/api/searchreturn 404, and every docs link on the site points at the same page on https://sessclone.com/docs. - The sitemap and
llms.txtlist only what the deployment serves.
The flags are read in two places. The Proxy reads them on every request, for
the 404s and the redirect from /; the public pages read them when they are
prerendered at build time, for their links and buttons. Changing a flag
needs a rebuild and redeploy, not only a restart: on Vercel, redeploy after
changing the variable; with Docker, rebuild the image (compose.yaml passes
the three as build arguments).
Live demo (optional)
| Variable | Required | Default | What it is |
|---|---|---|---|
ENABLE_DEMO | no | off | true runs a read-only demo at /demo (see Public site above) |
CRON_SECRET | with ENABLE_DEMO | — | Bearer secret for /api/demo/refresh. Unset means the route refuses every call |
Off by default, so a self-hosted copy has no demo unless you opt in. With
ENABLE_DEMO=true, /demo sets a cookie that shows a visitor with no session two
made-up Orgs, as Owner of one and Member of the other; every save answers
"This is a demo", because each of the visitor's database transactions is
read-only. A signed-in person always sees their own Orgs.
The data is generated, never copied from real usage: six invented people per
Org over the last 60 days. GET or POST /api/demo/refresh with
Authorization: Bearer <CRON_SECRET> creates the demo Orgs when missing,
deletes what is older than 60 days, and fills any missing day, so the first
call backfills the whole window. It is idempotent. On Vercel,
apps/web/vercel.json calls it daily at 23:00 UTC; elsewhere, schedule it
yourself once a day, after 23:00 UTC. It writes as INGEST_DATABASE_URL, and
stores a few hundred kilobytes of made-up transcripts when storage is
configured.
Toggling ENABLE_DEMO needs a redeploy. The landing and pricing pages are
prerendered at build time, so their "Try the demo" button appears or
disappears only with the next build. /demo, the banner and the refresh read
the variable per request and follow it at once, so turning it off without a
redeploy leaves a button that leads to a 404.
Retention sweep
| Variable | Required | What it is |
|---|---|---|
RETENTION_SWEEP_SECRET | no | Shared secret for POST /api/retention/sweep. Unset means the route refuses every call |
Retention is a window per Org, in days, set by an Owner or an Admin under Org
settings and capped by the Org's Tier. Nothing enforces it on a schedule,
because your scheduler is yours: POST /api/retention/sweep with
Authorization: Bearer <secret> removes transcripts past their window — the
rows and the stored objects together, oldest first, at most 500 per call — and
answers { removed, remaining } so a backlog can be drained by calling again.
Turns are never touched: the spend history survives every transcript it
describes.
The same call does two more daily jobs: it removes the transcripts of an Org
that moved to a Tier without transcripts once its 7 days are up, and it
finishes account deletions whose 14-day grace has ended. Its answer adds
accounts with how many were scrubbed and how many sign-ins were removed.
Set the windows before the first sweep
Upgrading an existing deployment sets every Org to 90 days, which no Org
chose. The first sweep on a deployment that has been collecting for longer
destroys every transcript older than that. Set the windows first — update orgs set retention_days = 365; or per Org — then configure the secret.
The route answers 401 for a wrong secret and 503 when the secret is unset,
when storage is not configured, or when the sweep failed (which rolls the rows
back).
Published pricing
| Variable | Required | What it is |
|---|---|---|
PRICING_URL | no | Markdown pricing page, e.g. https://platform.claude.com/docs/en/about-claude/pricing.md |
PRICING_REVALIDATE_SECRET | no | Shared secret for POST /api/pricing/revalidate. Unset means the route refuses every call |
PRICING_URL is read by Fetch latest pricing on /admin/rates. Nothing
fetched is written until a platform admin approves it.
The public pricing pages cache the Tier table. Saving a Tier in the admin
panel clears that cache itself; POST /api/pricing/revalidate with
Authorization: Bearer <secret> is for a Tier changed outside the panel. The
cache is per instance, so several instances or a serverless deployment each
refresh on their own schedule unless you add a shared cache handler.
Docker Compose
Read by compose itself, from the .env at the repository root.
| Variable | Required | Default | What it is |
|---|---|---|---|
WEB_PORT | no | 3000 | Host port the dashboard is published on. Deliberately not PORT |
POSTGRES_PASSWORD | with db only | — | Password for the sessclone role in the bundled Postgres (--profile db) |
POSTGRES_PORT | no | 5432 | Host port the bundled Postgres is published on, bound to loopback |
WEB_PORT is not PORT because next start reads PORT as the port it
listens on inside the container, and the same file is handed to both.
Collector
The Collector reads its configuration from the environment, or from the answers given when the plugin was enabled, so your team can point at your deployment without editing the plugin. An exported variable wins over the answer.
| Variable | Required | Default | What it is |
|---|---|---|---|
SESSCLONE_URL | no | https://sessclone.com | Base URL of the deployment. Matches the server's NEXT_PUBLIC_APP_URL |
SESSCLONE_STATE_DIR | no | per platform, below | Where the cursors and the retry queue are kept |
SESSCLONE_DEVICE | no | derived | Pins this environment's Device key instead of deriving one |
SESSCLONE_DEBUG | no | — | Set to anything to print a hook's swallowed failure to stderr. Off, a hook is silent |
There is no variable for the API key: it comes only from the plugin's own
setup prompt (url and api_key, both optional, in
packages/plugin/.claude-plugin/plugin.json), stored in Claude Code's secure credential store — the
Claude plugin directory refuses a plugin that reads a credential from the
user's environment and sends it on. SESSCLONE_URL wins over the prompt's
url answer when both are set. With no key at all, reports carry no
Authorization header, which is what lets a Claude Code cloud environment's
own proxy credential stand in for it.
An unset SESSCLONE_URL (and no url answer) now reports to the hosted
service at https://sessclone.com, not to a laptop that never answers — so a
self-hoster must set one or the other, or a machine quietly reports its usage
to somebody else's deployment. A value that is not an http or https URL is
refused at session start.
Connected, and to which Org. hooks/session-start.mjs asks
GET /api/ingest, which answers { org: <name> } for a live key (or a live
proxy-added credential) and the same 401 a report would get otherwise, and
marks a live key as used. The hook records the answer in the state directory
(connection.json: the state, the Org, the URL, the key's first three
characters and length, never the key) and prints one line: "sessclone
connected: reporting to <Org>." the first session after a key or deployment
changes, and "sessclone is not connected: …" every session for as long as the
deployment refuses it or none is set. While it is refused the Collector sends
nothing, rather than resending a session's history on every turn. Claude Code
gives the key to hooks and to the plugin's mod only: /sessclone-status prints
that record with the Device, this session's unsent Turns, the reports waiting
and the last push (/sessclone:status, where mods cannot load, all but the
unsent Turns).
/sessclone-sync runs the session-start check and sweep at once, with the key
the mod hands it; /sessclone:sync leaves a request that the Stop hook
ending the same turn carries out.
The Device key is normally host:<hostname> on a machine and
cloud:<account uuid> in Claude Code on the web. Set SESSCLONE_DEVICE only
when one account runs several environments that should be counted separately —
a CI fleet beside a laptop, say. The value is used verbatim, so it also pins an
identity across a rename.
The state directory deliberately never lands under ~/.claude, because
Claude Code's own cleanupPeriodDays sweep deletes under ~/.claude/projects/
after 30 days by default and would take the cursors and the queue with it:
| Platform | Default state directory |
|---|---|
| Linux | $XDG_STATE_HOME/sessclone, else ~/.local/state/sessclone |
| macOS | ~/Library/Application Support/sessclone |
| Windows | %LOCALAPPDATA%\sessclone, else %USERPROFILE%\AppData\Local\sessclone |
Only the Linux row has been observed on a real machine so far; treat the other two as the intended behaviour.
HTTPS_PROXY (or https_proxy) is honoured: when one is set, the Collector
restarts its hook once with NODE_USE_ENV_PROXY=1 so Node's fetch goes
through the proxy. That is what a Claude Code cloud environment needs, since
its egress proxy is what adds the credential. You never set
NODE_USE_ENV_PROXY yourself.
CLAUDE_CONFIG_DIR is Claude Code's variable. The Collector reads it —
CLAUDE_CONFIG_DIR, else ~/.claude, exactly as Claude Code does — to find
transcripts, and never writes inside it.
Not configured here
- Rates. Prices ship as reviewed migrations and are edited in the platform admin area, never read from the environment. A price in an env var is a price nobody reviewed.
- Tiers and retention windows. Rows in the database, set per Org.
- The Org timezone. An Org setting, because two Orgs on one deployment bucket their days differently.