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

deploy/self-host
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.

the two lists, both fail closed
# 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.

.env
AUDITANT_STORE=postgres
AUDITANT_PG_URL=postgres://auditant:…@db:5432/auditant
AUDITANT_PG_SCHEMA=auditant
move an existing log across, idempotently
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.json

Upgrades are git pull && docker compose up -d --build. The volume carries the chain across image changes — a deploy replaces the process, never the evidence.