Self-hosting guide

Run the whole thing on your own hardware.

Two Docker Compose stacks: ingest only, or everything including the dashboard and accounts. Nothing phones home either way.

Start here

If you just want it running and do not care about the options yet, do exactly this. It gives you the full product — dashboard, accounts, everything — on your own machine.

You need: Docker installed and running, about 4 GB of free memory, and a copy of the repository.

Step 1. Open a terminal in the repository and go into the sentrinode folder:

cd sentrinode

Step 2. Create the passwords and keys. This writes them to a file for you — you never type them yourself:

python selfhost/generate-keys.py > .env.selfhost-full
cat .env.selfhost-full.example >> .env.selfhost-full

Step 3. Start everything. The first run downloads and builds images, so give it five to ten minutes:

docker compose -f docker-compose.selfhost-full.yml --env-file .env.selfhost-full up -d --build

Step 4. Open http://localhost:3000 in a browser and create your account. That first account is yours; there is no email confirmation step.

Did it work?

Run this. Every line should say running or healthy:

docker compose -f docker-compose.selfhost-full.yml ps

And this should print {"ok":true}:

curl http://localhost:8000/healthz

If both look right, you are done. Create an API key in the dashboard under API Keys, then point your app at it.

Something went wrong? Jump to troubleshooting — the three most common problems are listed first. If you are stuck, email support@sentrinode.com with the output of docker compose -f docker-compose.selfhost-full.yml logs --tail=50.

Before anyone else can reach it: open .env.selfhost-full, set DISABLE_SIGNUP=true, and run step 3 again. Otherwise anyone who opens the page can create an account.

Everything below is detail you only need when you want it — a lighter option, configuration, backups, upgrades.

The lighter option

A · Ingest onlyB · Full stack
DashboardNo — read the /v1 APIYes
User accountsNoYes, local
External accountsNoneNone
Containers39
RAM~250 MB~1.3 GB
Compose filedocker-compose.selfhost.ymldocker-compose.selfhost-full.yml

The steps above install B, which is what most people want. A is worth it only if you already have your own dashboards and just need collection — it is three containers instead of nine and runs in a fraction of the memory.

These stacks have not been booted end to end by us. They are assembled from the same images and configuration the hosted product runs, and every file is validated, but the first person to run them should expect to fix something — most likely an image tag that has moved on. Tell us what broke and it gets fixed: support@sentrinode.com.

Requirements

  • Docker Engine 24+ with the Compose plugin (docker compose, not docker-compose).
  • RAM — 1 GB for A, 4 GB for B. B idles around 1.3 GB, but building the dashboard image alone can take 1–2 GB transiently, so build on a machine with headroom or build elsewhere and push.
  • Disk — 10 GB is comfortable. Trace files are capped (see configuration).
  • Python 3 on the host for stack B, to generate secrets once.
  • Repository access. The repo is private — email support@sentrinode.com.

Ports

PortServiceStack
8000Ingest — where your apps send telemetryA and B
3000DashboardB
8080APIB
8001Auth + REST gatewayB

Every other service — Postgres, Redis, Neo4j, GoTrue, PostgREST — is reachable only inside the Compose network and publishes nothing.

A · Ingest only

Three containers: Postgres for workspaces and keys, Redis for rate limiting and month-to-date spend, and the ingest itself. No Supabase, no dashboard.

cd sentrinode
cp .env.selfhost.example .env.selfhost

Edit .env.selfhost and set two values. Generate both rather than inventing them:

openssl rand -base64 24    # POSTGRES_PASSWORD
openssl rand -hex 32       # SENTRINODE_INTERNAL_API_KEY
docker compose -f docker-compose.selfhost.yml --env-file .env.selfhost up -d --build

The database schema and the key-minting helpers are applied automatically on first boot. Confirm it came up:

curl -s http://localhost:8000/healthz
# {"ok":true}

B · Full stack, with dashboard

Runs everything locally: a database, an accounts service, and the dashboard, alongside SentriNode itself. These are Supabase's own open-source components (Postgres, GoTrue, PostgREST), so the code is identical to the hosted product — it just runs on your machine. Nothing is hosted elsewhere.

1. Generate secrets

Do not hand-write these. ANON_KEY and SERVICE_ROLE_KEY are JWTs signed with JWT_SECRET, and PostgREST reads the role claim inside them to decide which database role a request runs as. Invented values are rejected on every request.

python selfhost/generate-keys.py > .env.selfhost-full
cat .env.selfhost-full.example >> .env.selfhost-full

2. Set your URLs

Edit .env.selfhost-full. These must be reachable from a browser, not from inside the Compose network — they are compiled into the dashboard's JavaScript at build time, so a service name like http://auth:9999 would resolve for the container and fail for your users.

# local machine
SUPABASE_PUBLIC_URL=http://localhost:8001
API_PUBLIC_URL=http://localhost:8080
DASHBOARD_URL=http://localhost:3000

# a real server
SUPABASE_PUBLIC_URL=https://auth.example.com
API_PUBLIC_URL=https://api.example.com
DASHBOARD_URL=https://sentrinode.example.com

Changing these later means rebuilding the dashboard image, not just restarting it: docker compose -f docker-compose.selfhost-full.yml up -d --build sentrinode-ui.

3. Bring it up

docker compose -f docker-compose.selfhost-full.yml --env-file .env.selfhost-full up -d --build

First run pulls images and builds three of them; several minutes is normal. Watch progress with docker compose -f docker-compose.selfhost-full.yml ps.

4. Create your account

Open http://localhost:3000 and sign up. MAILER_AUTOCONFIRM=true ships as the default, so the account is usable immediately without SMTP — otherwise every sign-up would sit unconfirmed with nothing able to clear it.

Once your account exists, set DISABLE_SIGNUP=true and restart the auth service. Otherwise anyone who reaches the dashboard can register.

docker compose -f docker-compose.selfhost-full.yml --env-file .env.selfhost-full up -d auth

Creating API keys

On stack B, create them in the dashboard under API Keys, the same as the hosted product.

On stack A there is no dashboard, so mint them in Postgres. Only a SHA-256 hash is stored, so the raw key is printed once and is not recoverable:

docker compose -f docker-compose.selfhost.yml --env-file .env.selfhost \
  exec postgres psql -U sentrinode -d sentrinode
select sentrinode_create_tenant('my-team');
select sentrinode_create_api_key('my-team', 'laptop');
-- snk_...  copy it now

Revoke one without deleting the record:

update api_keys set revoked_at = now() where key_prefix like 'snk_abcd%';

Revocation takes effect within about a minute — that is the auth cache TTL.

Sending data to it

Point the SDK at your own ingest instead of ours:

import os, sentrinode_llm, anthropic

sentrinode_llm.instrument(
    tenant="my-team",
    api_key=os.environ["SENTRINODE_API_KEY"],
    endpoint="http://your-server:8000",
)

Or send OTLP directly from anything — LangChain, LlamaIndex, OpenLLMetry, LiteLLM, the Vercel AI SDK, or a raw exporter:

POST http://your-server:8000/v1/otlp/traces
X-Tenant-Id:      my-team
X-SentriNode-Key: snk_...

Costs are computed on the server from the model and token counts, so emitters that do not send a price still get accurate spend.

Configuration

Trace history

Both stacks write one JSON object per LLM call to /data/traces on a named volume, per workspace per UTC day.

VariableDefaultWhat it does
TRACE_RETENTION_DAYS30Delete files older than this
TRACE_MAX_TOTAL_MB2048Total cap; oldest deleted first

The size cap is the one that matters. Age alone lets a busy month fill the disk, and a full volume stops ingestion entirely.

Limits

VariableDefault
INGEST_RATE_LIMIT_RPM600 requests/minute per workspace
INGEST_MAX_BODY_BYTES1048576 (1 MB)
INGEST_MAX_SPANS_PER_REQUEST2000
INGEST_NODE_TTL_SECONDS900 — a node stops being listed after this

Model pricing

39 models across 8 providers ship built in. Prices drift, so override any of them without rebuilding by pointing SENTRINODE_PRICING_FILE at a JSON file. Overrides merge, so changing two models does not lose the rest.

{"my-finetune": [1.5, 6.0], "claude-opus-4": [15.0, 75.0]}
// USD per 1,000,000 tokens: [input, output]

Optional

ANTHROPIC_API_KEYEnables the AI panels. Without it they return 503 and everything else is unaffected.
RESEND_API_KEYEmail alerts for budgets and anomalies.
INFLUX_*Per-call history in InfluxDB as well as on disk. All four required together.

Operating it

# is everything up
docker compose -f docker-compose.selfhost-full.yml ps

# follow a service
docker compose -f docker-compose.selfhost-full.yml logs -f sentrinode-ingest

# health
curl -s http://localhost:8000/healthz     # ingest
curl -s http://localhost:8080/health      # API — reports its dependencies

The API's /health reports database connectivity, not just that the process is alive. A stack whose auth database is unreachable will answer cheerfully on /healthz while every authenticated request fails, so check this one.

Reading trace files directly

docker compose -f docker-compose.selfhost-full.yml exec sentrinode-ingest ls -R /data/traces

# today's spend for one workspace
docker compose -f docker-compose.selfhost-full.yml exec sentrinode-ingest \
  sh -c "cat /data/traces/my-team/$(date -u +%F).jsonl" | jq -s 'map(.cost_usd) | add'

Backups

Everything durable lives in named volumes. Back up the database and the traces; Redis holds month-to-date spend and is worth keeping, but is rebuildable from the traces if lost.

# Postgres (stack B)
docker compose -f docker-compose.selfhost-full.yml exec -T db \
  pg_dumpall -U postgres | gzip > sentrinode-db-$(date +%F).sql.gz

# Postgres (stack A)
docker compose -f docker-compose.selfhost.yml exec -T postgres \
  pg_dump -U sentrinode sentrinode | gzip > sentrinode-db-$(date +%F).sql.gz

# trace files
docker run --rm -v sentrinode_trace_data:/data -v "$PWD":/backup alpine \
  tar czf /backup/traces-$(date +%F).tar.gz -C /data .

An untested backup is not a backup. Restore one into a scratch stack before you need it — that is the only way to learn your volume names are wrong while it is still cheap.

Upgrading

git pull
docker compose -f docker-compose.selfhost-full.yml --env-file .env.selfhost-full up -d --build

Compose only recreates containers whose definition or image changed, so unchanged services keep running. Database migrations in supabase/migrations/ are applied to a new database only — for an existing one, apply new files yourself:

docker compose -f docker-compose.selfhost-full.yml exec -T db \
  psql -U postgres -d postgres < supabase/migrations/<new-file>.sql

They are written so that running the same one twice does no harm.

Security

  • Do not put these ports straight on the internet. They serve plain HTTP, with no encryption. Run something in front that adds HTTPS — Caddy is the least work, since it obtains and renews certificates on its own.
  • Set DISABLE_SIGNUP=true once your accounts exist.
  • Keep the ingest port reachable only by your applications. It is authenticated, but there is no reason to expose it to the internet if your services are internal.
  • Treat SERVICE_ROLE_KEY like a database password. It ignores every access rule in the database and can read anyone's data. It belongs in the env file and nowhere else — never in the browser, never committed to a repository.
  • The env file holds every secret. chmod 600 it.

Troubleshooting

Dashboard loads but login fails

Almost always SUPABASE_PUBLIC_URL pointing somewhere the browser cannot reach. It is baked into the JavaScript at build time, so check what the browser actually requested in the network tab, then fix the value and rebuild — not just restart — sentrinode-ui.

Everything is "healthy" but the dashboard shows nothing

Check auth and rest can reach the database. The Supabase images ship those roles without passwords, and the init script sets them on first boot only — if the volume was created before that script existed, the roles have no password and both services fail to connect while the containers still look fine.

docker compose -f docker-compose.selfhost-full.yml logs auth | tail -30

401 on every request from your app

The workspace slug and key must both match. Confirm the key exists and is not revoked:

select t.slug, k.name, k.revoked_at, k.expires_at
  from api_keys k join tenants t on t.id = k.tenant_id;

429 responses

You are over INGEST_RATE_LIMIT_RPM, which counts requests, not spans. Send fewer, larger batches before raising the limit — 600 requests of 100 spans is 60,000 spans a minute.

Ingest keeps restarting

Usually Postgres or Redis not being ready, or a bad DATABASE_URL. The logs name the cause:

docker compose -f docker-compose.selfhost.yml logs sentrinode-ingest | tail -40

Disk filling up

Lower TRACE_MAX_TOTAL_MB and restart the ingest; pruning runs on the next flush and removes oldest files first. Check current usage with docker system df -v.

Out of memory during build

The dashboard build is the spike, not the running stack. Build it on a larger machine and push the image, or add swap.