DocsSelf-hostingBrowse
Self-hosting
Nothing in the architecture assumes our cloud: the chain, the policy engine, tenancy and approvals run from one container against one volume, built on Node builtins. The only outbound calls are the anchors you enable — which is the point of them.
Run it
cd deploy/self-host cp .env.example .env # keys, log id, anchors — all commented openssl rand -hex 32 # → AUDITANT_API_KEYS mkdir -p data openssl ecparam -genkey -name prime256v1 -noout -out data/signing.pem docker compose up -d --build curl localhost:4319/health
Point the SDKs' endpoint and the dashboard's AUDITANT_API at it, and terminate TLS with whatever you already run — the container speaks plain HTTP on 4319 and never needs to be public.
What to protect
The volume is the evidence — back it up like the record it is; append-only is enforced by triggers inside SQLite, not process discipline. The signing key must survive restarts — checkpoints signed before a restart can only be extended by the key that signed them. Anchors are what make it evidence to outsiders: RFC 3161 costs nothing and is on by default; the S3 Object Lock copy (a bucket in your account, lock enabled at creation) survives even you.
Who can sign in, and as what
Standing on a log is a seat in the control plane, not an environment variable. Three seats: owner (rules, budgets, keys, the halt, and who else holds a seat), approver (decides held actions, authors nothing) and auditor (reads and exports, changes nothing). The first person to sign in to a fresh log is seated as its owner; everyone after them is invited by an owner from the console's Team page and lands seated on their first sign-in. Every grant and revocation is a lifecycle event on the log's own chain.
# dashboard — bootstrap. Listed emails are seated as owner of AUDITANT_LOG_ID # on sign-in (a chained grant). Unset means nobody is pre-authorised. AUDITANT_ALLOWED_EMAILS=you@yourco.com # control plane — override. Listed emails hold every capability on every log. # Unset means nobody; the seats in the store are the only standing there is. AUDITANT_ADMIN_EMAILS=you@yourco.com
Postgres, when one file is the wrong shape
SQLite is the store until you need more than one control-plane process, a managed backup story, or a log that has outgrown a single writer. Postgres (14+) is the same schema with the same append-only trigger — plus one on TRUNCATE, which bypasses row triggers — selected by environment, and moving to it re-chains nothing: every event is copied verbatim and the whole chain is re-verified before the tool says it is safe to cut over.
AUDITANT_STORE=postgres AUDITANT_PG_URL=postgres://auditant:…@db:5432/auditant AUDITANT_PG_SCHEMA=auditant
AUDITANT_DB=./data/auditant.db AUDITANT_PG_URL=postgres://… pnpm migrate:postgres # …copies keys, events, checkpoints, tenants and rules; then: # ✓ yourco/prod: 4183 events, head sha256:bb1074a2c9e1…, chain re-verifies from genesis # every log's head matches and every chain re-verifies — safe to cut over
The signing key stays where it is — checkpoints are signed by the key, not by the store — and the new plane extends the same chain under it. Step by step, including the least-privilege grants, in deploy/self-host/README.md.
Verify it behaves like the hosted one
curl -s "localhost:4319/v1/export?log=yourco%2Fprod" -H "authorization: Bearer $KEY" -o bundle.json
node -e "const b=require('./bundle.json');require('fs').writeFileSync('verify.mjs',b.verifier)"
node verify.mjs bundle.jsonUpgrades are git pull && docker compose up -d --build. The volume carries the chain across image changes — a deploy replaces the process, never the evidence.