Titentiten.devꦠꦶꦠꦺꦤ꧀
v0.1.1 · npmGitHub
DocsDeployBun + SQLite (VPS)

Bun + SQLite (VPS)

One Bun process, one SQLite file, a hardened systemd unit, and a backup that verifies itself.

Bun 1.3+one process55 MiB RSSDocker optional

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'
The key is printed once

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)
SERVE FLAGS

--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.

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.

/etc/systemd/system/titen.serviceini
[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.

Do not put customer traffic on /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.

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

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.

The base image must use glibc

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.

Next

↑↓ navigate↵ open