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 only | B · Full stack | |
|---|---|---|
| Dashboard | No — read the /v1 API | Yes |
| User accounts | No | Yes, local |
| External accounts | None | None |
| Containers | 3 | 9 |
| RAM | ~250 MB | ~1.3 GB |
| Compose file | docker-compose.selfhost.yml | docker-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, notdocker-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
| Port | Service | Stack |
|---|---|---|
8000 | Ingest — where your apps send telemetry | A and B |
3000 | Dashboard | B |
8080 | API | B |
8001 | Auth + REST gateway | B |
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.
| Variable | Default | What it does |
|---|---|---|
TRACE_RETENTION_DAYS | 30 | Delete files older than this |
TRACE_MAX_TOTAL_MB | 2048 | Total 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
| Variable | Default |
|---|---|
INGEST_RATE_LIMIT_RPM | 600 requests/minute per workspace |
INGEST_MAX_BODY_BYTES | 1048576 (1 MB) |
INGEST_MAX_SPANS_PER_REQUEST | 2000 |
INGEST_NODE_TTL_SECONDS | 900 — 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_KEY | Enables the AI panels. Without it they return 503 and everything else is unaffected. |
RESEND_API_KEY | Email 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=trueonce 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_KEYlike 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 600it.
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.