Bun + SQLite (VPS)
The production path — one Bun process, one SQLite file, a hardened systemd unit, TLS at the edge, 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. No Docker, no queue, no vector service, no
model. Everything optional stays optional at runtime, and /readyz reports which optional
pieces are actually 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. There is no reissue path — a lost key means minting a new one
with titen key create --org-id <id>. Keep the credential outside the repository, mode
0600, for example in ~/.config/titen/env.
titen migrate --db … exists for the upgrade case: it 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.db canonical database path; WAL sidecar files 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
Host, port and database path are flags, not environment variables. Only the optional embedding and maintenance settings are read from the environment, because one of them is a credential and all of them are deployment facts that belong in a unit file rather than a shell history.
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 is written but 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 — because 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.
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.
| Property | Measured |
|---|---|
| Route coverage | 58 of 58, 45 assertions passing |
| 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 all 58 routes.
Container install
Optional — the service runs fine directly under Bun. 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.