# Bun + SQLite (VPS)

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

Section: Deploy · Source: https://titen.dev/docs/deploy-vps
Derived from: https://github.com/RamaAditya49/titen/blob/main/docs/deployment/vps.md

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

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

```sh
sudo -u titen bun /opt/titen/src/runtime/bun/cli.ts \
  bootstrap --db /var/lib/titen/titen.db --org 'My Org'
```

<div class="callout callout--alarm">
<strong>The key is printed once</strong>

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`.
</div>

On an upgrade, `titen migrate --db …` applies pending migrations to an existing database
and prints the resulting schema version.

## Serve

```sh
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)
```

<div class="kv">
<strong>SERVE FLAGS</strong>

<p><code>--db titen.db</code> canonical database path; WAL sidecar files land beside it</p>
<p><code>--host 127.0.0.1</code> bind address, loopback by default</p>
<p><code>--port 8787</code> listen port</p>
<p><code>--revision dev</code> build marker echoed by <code>/healthz</code> and <code>/readyz</code></p>
</div>

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](https://github.com/RamaAditya49/titen/blob/main/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.

<figure class="code"><figcaption>/etc/systemd/system/titen.service<b>ini</b></figcaption>

```ini
[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
```

</figure>

`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](https://github.com/RamaAditya49/titen/blob/main/deploy/caddy/Caddyfile)
is the shipped example: HSTS, `nosniff`, `X-Frame-Options: DENY`, `Server` header stripped.

<div class="callout callout--alarm">
<strong>Do not put customer traffic on <code>/v1</code></strong>

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

<div class="stop"></div>

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

<div class="rules">

<p>With a vector capability configured, the timer runs every 15 s and <code>/readyz</code> reports <code>background_repair: "enabled"</code>.</p>
<p>With neither a vector capability nor an explicit interval, the timer is <strong>off</strong> and readiness reports <code>"disabled"</code>. Set <code>TITEN_MAINTENANCE_INTERVAL_MS</code> if you have webhooks but no vectors.</p>
<p>Set the interval to <code>0</code> when an external scheduler owns the work, then drive <code>POST /v1/index/drain</code> and <code>POST /v1/webhooks/deliver</code> yourself.</p>

</div>

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.

<div class="table-wrap">

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

</div>

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.

<div class="callout">
<strong>The base image must use glibc</strong>

`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.
</div>

## Next

<div class="cards">

<a href="/docs/vectors"><strong>Semantic vectors</strong><span>Wire an embedding endpoint and sqlite-vec, and see what degrades without them.</span></a>
<a href="/docs/backup-export"><strong>Backup &amp; export</strong><span>The verified online copy, the daily timer, and what a restore actually guarantees.</span></a>
<a href="/docs/keys-scopes"><strong>Keys &amp; scopes</strong><span>One revocable credential per agent, with a trust ceiling it cannot exceed.</span></a>

</div>