Bun + SQLite (VPS)
One Bun process, one SQLite file, a hardened systemd unit, and a backup that verifies itself.
This is the runtime Titen is verified live on: one Bun.serve process, one SQLite database
in WAL mode, FTS5 inside that same database. Docker, a queue, a vector service and a model
are all optional, and /readyz reports which of them are present.
Prepare the host
Bun 1.3+ and SQLite 3.45+ (FTS5). A dedicated non-root user owns the state directory.
sudo useradd -r -s /bin/false titen
sudo mkdir -p /opt/titen /var/lib/titen
sudo chown titen:titen /var/lib/titen
sudo chmod 700 /var/lib/titen
Bootstrap the organization
bootstrap applies every migration and mints the first key in one step, so there is no
separate migrate call on a fresh host. Run it as the service user, or the database ends up
owned by root.
sudo -u titen bun /opt/titen/src/runtime/bun/cli.ts \
bootstrap --db /var/lib/titen/titen.db --org 'My Org'
Titen stores only its hash. A lost key cannot be reissued; mint a new one with
titen key create --org-id <id>. Keep the credential outside the repository, mode
0600, for example in ~/.config/titen/env.
On an upgrade, titen migrate --db … applies pending migrations to an existing database
and prints the resulting schema version.
Serve
bun src/runtime/bun/cli.ts serve \
--db /var/lib/titen/titen.db --host 127.0.0.1 --port 8787
# titen listening on http://127.0.0.1:8787 (database /var/lib/titen/titen.db)
--db ~/.titen/service.db canonical database path when omitted; explicit service units should keep an absolute path, and WAL sidecars land beside it
--host 127.0.0.1 bind address, loopback by default
--port 8787 listen port
--revision dev build marker echoed by /healthz and /readyz
serve refuses a missing database instead of silently creating an empty store. Run
bootstrap first. Releases before 0.8.0 defaulted to ./titen.db; when that legacy file
would be bypassed, an unflagged command stops and prints the exact --db path to keep using
it. Titen never relocates an existing store automatically.
Host, port and database path are flags, not environment variables. Only the optional embedding and maintenance settings are read from the environment.
Install the unit
The repo ships a hardened unit at
deploy/titen.service.
The live verification ran the service containerized under a rootless user unit, so the systemd
and reverse-proxy wiring below has not yet run on a provisioned host under the titen user.
[Service]
Type=simple
User=titen
Group=titen
StateDirectory=titen
ExecStart=/usr/local/bin/bun /opt/titen/src/runtime/bun/cli.ts serve \
--db /var/lib/titen/titen.db --port 8787 --host 127.0.0.1
Restart=on-failure
RestartSec=5
StartLimitBurst=5
StartLimitIntervalSec=60
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/titen
UMask=0077
LimitNOFILE=65536
MemoryMax=512M
ProtectSystem=strict plus a single ReadWritePaths entry means the process can write to
its state directory and nowhere else. The service handles SIGTERM itself: it clears the
maintenance timer, stops the HTTP server and closes SQLite. Otherwise the stop times out,
the process is killed with the database open, and every restart pays a WAL recovery it did
not need. Graceful shutdown takes about 130 ms.
Terminate TLS at the edge
The service binds loopback, so a proxy goes in front of it.
deploy/caddy/Caddyfile
is the shipped example: HSTS, nosniff, X-Frame-Options: DENY, Server header stripped.
/v1
For customer-facing use, route public traffic to your CRM or chatbot application, which
holds a gateway credential pinned to its channel and derives the customer subject
server-side. Proxy rules must not expose arbitrary /v1 or /mcp paths on that public
route. Titen itself never exposes anonymous memory search.
What background repair does here
Embedding calls a model over the network and webhook delivery calls someone else’s server, so neither can run inside a canonical write. Both are pulled from a queue afterwards by a bounded in-process timer.
With a vector capability configured, the timer runs every 15 s and /readyz reports background_repair: "enabled".
With neither a vector capability nor an explicit interval, the timer is off and readiness reports "disabled". Set TITEN_MAINTENANCE_INTERVAL_MS if you have webhooks but no vectors.
Set the interval to 0 when an external scheduler owns the work, then drive POST /v1/index/drain and POST /v1/webhooks/deliver yourself.
Overlapping passes are skipped rather than queued, so a slow model cannot stack them, and the timer is unrefed so it never holds the process open.
Measured on a real host
Fedora 44, 16 cores, 30 GiB RAM, rootless podman, embeddings from a local Ollama serving
embeddinggemma at 768 dimensions. Measured 2026-08-01, when the live route verifier
covered 78 routes; the current router declares 96, and this live run has not been
repeated against it.
| Property | Measured |
|---|---|
| Route coverage | 78 of 78 in the route verifier |
| Context compile, real embedding call | p50 100 ms, p95 114 ms |
| Observation append | p50 0.5 ms, p95 3.5 ms |
| Index drain, one embedding batch | p95 188 ms |
| Webhook drain and delivery | p50 21 ms |
| Resident set size after a full run | 55 MiB |
| Idle CPU over 60 s | 0.42% of one core |
| Recovery after the process is killed | 2.6 s, unattended |
| Canonical database, 23 claims + 23 observations | 556 KiB plus a 3.7 MiB WAL |
| Vector index, same corpus | 3.1 MiB |
Also verified live: data survives restart with no rebuild, FTS5 retrieval works with no
model configured, an unreachable embedding endpoint still returns a lexical pack while
POST /v1/index/drain reports 503 UNAVAILABLE with the queue intact, and a foreign
organization can read nothing across the 78 routes that run covered.
Container install
Bind 0.0.0.0 inside the container and publish only the host address you mean;
--network host exposes the service anywhere the host is reachable.
sqlite-vec ships a glibc-linked prebuilt binary. On Alpine it fails to load with
__memcpy_chk: symbol not found. The service still starts and serves; it loses vector
retrieval silently. That is why the shipped Dockerfile is Debian-based. Podman also builds
OCI images, which ignore HEALTHCHECK: use podman build --format docker or probe
/healthz externally.