Portunus
Features

SQLite Storage

Unified embedded SQLite store. Bundled, WAL-mode, refinery-managed migrations. Backup/restore tooling included.

Available since v0.8.0. Collapses every server-side persistent JSON file (tokens.json, identity.json, rules.json) and the in-memory audit ring buffer into one embedded SQLite database at <data-dir>/state.db.

Why SQLite

  • Single-binary deployment unchangedrusqlite with the bundled feature statically links a pinned SQLite. No system libsqlite3 dependency.
  • Crash-safe — WAL mode + BEGIN IMMEDIATE write transactions.
  • Migrations — managed by refinery; schema lives at crates/portunus-server/src/store/migrations/.

Tables

Representative tables (schema evolves via migration; see crates/portunus-server/src/store/migrations/):

TableHolds
usersRBAC users (id, role, display name, disabled flag)
credentialsBearer-token hashes (blake3) + status
grantsPer-user (client, listen-port range, protocols) triples
rulesPushed rules (with v0.11 nullable cap columns)
rule_targetsMulti-target rule entries (v0.7+)
auditDurable audit log (replaces the v0.6 in-memory ring)
rate_limit_ownerPer-owner cap envelopes (v0.11+)
traffic_quotas, traffic_samples_*Per-(user, client) monthly usage + history (v0.13+)
web_sessions, login_attempts, onboarding_setupLocal password auth + first-run setup token (v1.1+)
client_enrollmentsOne-time client enrollment codes

Path layout

XDG-aligned default data-dir:

$XDG_STATE_HOME/portunus/    (or ~/.local/state/portunus/)
  server.toml          (optional — overrides built-in defaults)
  server.crt
  server.key
  state.db
  state.db-shm         (present while server running)
  state.db-wal         (present iff WAL frames pending)

Override with --data-dir. Production deployments typically use /var/lib/portunus/.

Filesystem checks

The server refuses to start with event=startup.unsupported_filesystem if --data-dir points at NFS, tmpfs, or ramfs. Move the data-dir to a local writable filesystem.

It also refuses two portunus-server serve processes against the same data-dir (event=startup.store_in_use, exit 75) — clustering is out of scope.

Backup

Online backup via rusqlite::backup::Backup — WAL-aware, no quiescing needed:

portunus-server backup --out ~/portunus-backup-$(date +%F).db

Refuses to overwrite an existing destination.

Restore

portunus-server reset --confirm     # wipe local state first
portunus-server restore --in ~/portunus-backup-2026-05-08.db
portunus-server serve               # schema migrations run automatically

restore validates the SQLite header magic and refuses to clobber a non-empty data dir without --force. Backups whose schema is newer than the binary's target version exit 78 with startup.schema_version_too_new — restore on the matching binary version, or upgrade.

Reset

portunus-server reset --confirm
# wipes state.db + state.db-wal + state.db-shm (+ state.db.lock)
# refuses any path whose first 16 bytes don't match the SQLite header

The header check protects against a typo'd --data-dir. reset leaves server.crt / server.key / server.toml in the same directory untouched. Without --confirm it is a dry run that prints the path it would remove.

Audit prune

The audit table is durable, not ring-buffered. Bound its size with the prune CLI:

# Dry run — count without deleting
portunus-server audit prune --before 2026-05-01T00:00:00Z --dry-run

# Real prune — deletes under BEGIN IMMEDIATE, then PRAGMA incremental_vacuum
portunus-server audit prune --before 2026-05-01T00:00:00Z

Run it from cron (daily / weekly) to keep the database bounded.

Audit envelope mode

GET /v1/audit?since=<RFC3339>&until=<RFC3339>&cursor=<base64>&limit=N returns { entries, next_cursor?, count } instead of the v0.7 array root, enabling cursor-based historic scroll-back.

v0.7 callers (no since / until / cursor) keep getting the array root unchanged — byte-stable.

portunus-client --bundle resolution

When --bundle is omitted the client searches in order:

  1. $PORTUNUS_CLIENT_BUNDLE
  2. $XDG_CONFIG_HOME/portunus/client.bundle.json
  3. $HOME/.config/portunus/client.bundle.json
  4. ./client.bundle.json

Exits 1 listing every attempted path when none resolve.

On this page