Deployment
Run Vaultbase in production — bare metal, Docker, behind a reverse proxy, with TLS and persistent storage.
Vaultbase is a single binary plus a <dataDir> folder. That’s the whole
deployment artifact. Treat it like SQLite + a small Node service.
What you ship
Section titled “What you ship”- The binary for your target (
vaultbase-linux-x64,vaultbase-macos-arm64, etc.). Linux binaries are also published as.xz(about 30 MB instead of ~85 MB); the one-shot installer downloads that whenxzis available and verifies the decompressed binary against the same.sha256/ signature. - An empty data directory — Vaultbase creates the DB and folders on first run
Verifying the release (cosign + SBOM)
Section titled “Verifying the release (cosign + SBOM)”Every release uploaded to GitHub is signed keyless via Sigstore — no static signing key, the cert chain proves the build came from the project’s GitHub Actions workflow. CycloneDX SBOMs ride alongside.
The one-shot installer takes a --verify-sig flag that runs the cosign
check before installing:
curl -fsSL https://get.vaultbase.dev | sudo sh -s -- --verify-sigOn Windows, the PowerShell installer takes -VerifySig:
& ([scriptblock]::Create((irm https://get.vaultbase.dev/install.ps1))) -VerifySigManual verify with cosign:
TAG=v0.1.6BIN=vaultbase-linux-x64
curl -L -o "$BIN" "https://github.com/vaultbase-sh/vaultbase/releases/download/$TAG/$BIN"curl -L -o "$BIN.sig" "https://github.com/vaultbase-sh/vaultbase/releases/download/$TAG/$BIN.sig"curl -L -o "$BIN.pem" "https://github.com/vaultbase-sh/vaultbase/releases/download/$TAG/$BIN.pem"
cosign verify-blob \ --signature "$BIN.sig" \ --certificate "$BIN.pem" \ --certificate-identity-regexp '^https://github\.com/vaultbase-sh/vaultbase/' \ --certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \ "$BIN"Each release also publishes a CycloneDX SBOM (<binary>.cdx.json) for
downstream trivy / grype / dependency-track ingestion, and a
vaultbase-source.cdx.json for the source tree.
Bare metal
Section titled “Bare metal”# On the serveruseradd --system --home-dir /srv/vaultbase --shell /usr/sbin/nologin vaultbasemkdir -p /srv/vaultbasechown vaultbase:vaultbase /srv/vaultbasecp vaultbase-linux-x64 /usr/local/bin/vaultbasechmod +x /usr/local/bin/vaultbase
# systemd unit at /etc/systemd/system/vaultbase.service[Unit]Description=VaultbaseAfter=network.target
[Service]Type=simpleUser=vaultbaseWorkingDirectory=/srv/vaultbaseEnvironment=VAULTBASE_DATA_DIR=/srv/vaultbase/dataEnvironment=VAULTBASE_PORT=8091ExecStart=/usr/local/bin/vaultbaseRestart=on-failureRestartSec=5
[Install]WantedBy=multi-user.target
# Thensystemctl daemon-reloadsystemctl enable --now vaultbaseLogs go to journald (journalctl -u vaultbase -f) plus the structured JSONL
logs in <dataDir>/logs/.
The install script does all of this for you
and writes a hardened unit like
deploy/systemd/vaultbase.service,
which keeps its data in /var/lib/vaultbase and reads its environment from
/etc/vaultbase/vaultbase.env.
Docker
Section titled “Docker”The image is published to the GitHub container registry for every release, for
linux/amd64 and linux/arm64: ghcr.io/vaultbase-sh/vaultbase, tagged with
the version (0.25.0), the minor version (0.25) and latest. It is the
signed release binary on Debian slim, checked against the release’s
SHA256SUMS when the image is built. The data folder is /data: mount a
volume there. The server runs as an unprivileged user; started as root, the
entrypoint first gives /data to that user, so volumes that hosts mount as
root work as they are.
# Once: generate the two keys and keep this fileprintf 'VAULTBASE_JWT_SECRET=%s\nVAULTBASE_ENCRYPTION_KEY=%s\n' \ "$(openssl rand -base64 32)" "$(openssl rand -base64 32)" > vaultbase.envchmod 600 vaultbase.env
docker run -d --name vaultbase \ -p 8091:8091 \ -v vaultbase-data:/data \ --env-file vaultbase.env \ -e VAULTBASE_READ_WORKERS=0 \ ghcr.io/vaultbase-sh/vaultbase:0.27.1docker logs vaultbase | grep "Setup key"Reuse the same vaultbase.env every time you recreate the container (for
example to upgrade): a new JWT secret signs everyone out, and a new
encryption key makes encrypted values unreadable.
Open http://<host>:8091/_/setup and create the first admin with that key.
- Read workers. A container sees the host’s cores, not its own limit, and
each read worker costs 50–100 MB. Set
VAULTBASE_READ_WORKERSto what the machine can spare (cores minus two;0on 1–2 vCPU). - Health checks. The image’s
HEALTHCHECKcalls/_/health(the process answers). Point a load balancer or orchestrator’s readiness probe at/_/ready. - Pin a version in production;
latestmoves with every release.
Docker Compose with HTTPS
Section titled “Docker Compose with HTTPS”deploy/docker/docker-compose.yml
runs the image with Caddy in front, which gets a certificate for your domain:
git clone https://github.com/vaultbase-sh/vaultbase && cd vaultbasecp deploy/docker/.env.example .env # set DOMAIN, generate both secretsdocker compose -f deploy/docker/docker-compose.yml --env-file .env up -ddocker compose -f deploy/docker/docker-compose.yml logs vaultbase | grep "Setup key"Point the domain’s DNS at the server first; Caddy needs ports 80 and 443 (or
set HTTP_PORT / HTTPS_PORT).
One-click hosts
Section titled “One-click hosts”Each host runs the published image with a persistent volume for /data, a
generated setup key, and health checks on /_/ready. Keep one instance: on
SQLite the database lives on that volume.
| Host | How | Notes |
|---|---|---|
| Render | Deploy to Render (from render.yaml) | A disk needs a paid instance (Starter or above). The setup key is in the service’s Environment tab. The encryption key is generated into /data/.encryption-key: back it up with the data. |
| Fly.io | fly launch --config deploy/fly/fly.toml --copy-config --no-deploy, then the commands in deploy/fly/fly.toml | One shared-cpu-1x machine with 512 MB and a 1 GB volume. Machines don’t stop when idle, so realtime and scheduled jobs keep running. |
| Railway | The Vaultbase template (recipe) | The setup key is in the service’s Variables tab. |
All three put their own proxy in front, so the templates set
VAULTBASE_TRUSTED_PROXIES to the private address ranges that proxy connects
from; that’s what lets rate limits and the realtime per-address cap see real
client addresses. Hosts without persistent volumes (such as DigitalOcean App
Platform) don’t suit SQLite; run the install script
on a droplet or VPS instead, or use Postgres.
Behind a reverse proxy
Section titled “Behind a reverse proxy”Vaultbase reads X-Forwarded-For for the client IP — set it on your proxy
and set VAULTBASE_TRUSTED_PROXIES (CIDR list) on the vaultbase process,
otherwise the header is silently ignored as a defensive default and every
request looks like it came from the proxy. See the env-vars table below for
details.
The header is only read when the connection comes from a trusted proxy, and
it is read from the right: the client address is the rightmost entry that
isn’t one of your proxies. Have the proxy set the header to the address
it saw ($remote_addr in nginx, {client_ip} in Caddy) rather than append
to whatever the client sent.
Complete templates live in the repository:
deploy/caddy/Caddyfile and
deploy/nginx/vaultbase.conf (its header
says how to raise nginx’s worker_connections). The
deploy notes cover the host settings that matter under
load.
# Caddyfileapi.example.com { reverse_proxy localhost:8091 { header_up X-Forwarded-For {client_ip} transport http { versions h1 h2 } }}Caddy handles TLS automatically and sets X-Forwarded-Proto itself.
server { listen 443 ssl http2; server_name api.example.com;
ssl_certificate /etc/letsencrypt/live/api.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;
client_max_body_size 100M;
# WebSocket needs Upgrade headers location /realtime { proxy_pass http://127.0.0.1:8091; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; proxy_read_timeout 86400; # keep WS open }
# SSE realtime fallback: don't buffer the stream location /api/v1/realtime { proxy_pass http://127.0.0.1:8091; proxy_http_version 1.1; proxy_buffering off; proxy_read_timeout 86400; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; }
location / { proxy_pass http://127.0.0.1:8091; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header X-Forwarded-Proto $scheme; }}X-Forwarded-Proto tells Vaultbase the client came over HTTPS, so auth
cookies get the Secure flag. Like X-Forwarded-For, it’s only read from a
trusted proxy.
Required env vars in production
Section titled “Required env vars in production”| Var | Recommended |
|---|---|
VAULTBASE_JWT_SECRET | 32+ bytes from openssl rand -base64 32. Persist; rotating invalidates every existing JWT. |
VAULTBASE_ENCRYPTION_KEY | 32 bytes from openssl rand -base64 32. Required for encrypted fields. Don’t lose it — losing it makes encrypted values unreadable. |
VAULTBASE_DATA_DIR | A persistent volume — never /tmp. |
VAULTBASE_PORT | If proxying, you can keep :8091 and not expose it publicly. |
VAULTBASE_TRUSTED_PROXIES | CIDR list (e.g. 127.0.0.1/32,10.0.0.0/8). Required behind any reverse proxy — without it, X-Forwarded-For is silently ignored and rate-limits / audit-log IPs / brute-force lockout all key on the proxy IP instead of the real client. |
VAULTBASE_HOST | 127.0.0.1 when a reverse proxy on the same host is the only way in. Default: every interface, IPv4 and IPv6. |
VAULTBASE_SETUP_KEY | Key that first-admin setup requires. Optional: without it the server generates one at first boot, prints it and keeps it in <dataDir>/.setup-key until an admin exists. |
Auto-generated values land in <dataDir>/.secret and
<dataDir>/.encryption-key if you don’t set them — but
in containers/ephemeral hosts that volume might not survive restarts, so set
both explicitly.
Bootstrapping the first admin
Section titled “Bootstrapping the first admin”Two safe ways to seed the very first superuser. Pick one — the choice is mostly about whether you want a one-shot CLI or to use the web wizard.
Option A — CLI (no public surface)
Section titled “Option A — CLI (no public surface)”The binary doubles as a setup tool. Run it once on the host before binding to a public port:
./vaultbase setup-admin --email ops@example.com --password 's3cr3t-please'Refuses to run when an admin already exists, unless you pass --force (which
adds another admin alongside the existing ones). No HTTP, no race.
On an installed service, setup-admin reads /etc/vaultbase/vaultbase.env
(or the file named by VAULTBASE_ENV_FILE) for variables not already set, so
it writes to the service’s database.
Option B — Web wizard with the setup key
Section titled “Option B — Web wizard with the setup key”While no admin exists, creating one needs a setup key. On first boot the server prints it, with a link that fills it in:
No admin exists yet. Create one at: http://localhost:8091/_/setup#key=9UzxnHtX…Setup key: 9UzxnHtX…Open the link (or paste the key into the wizard’s Setup key field), or
send it as an X-Setup-Key header to POST /api/v1/admin/setup. The key
lives in <dataDir>/.setup-key (mode 0600) until the first admin exists, so
it survives restarts. To choose it yourself, set VAULTBASE_SETUP_KEY; the
installer does this and prints it.
Health and readiness
Section titled “Health and readiness”Two probes, both public and outside the rate limiter:
GET /_/health— liveness. Answers200as long as the process is up and serving HTTP; it checks nothing else. Use it to decide whether to restart the process.GET /_/ready— readiness. Checks what the server needs to do its work. Point load balancer health checks and orchestrator readiness probes here.
| Check | What it does | Fails as |
|---|---|---|
database | A round trip to the database within 2 s | down → unready |
storage | A lookup in the configured file storage within 3 s | down → unready |
read_workers | Fewer read workers running than configured (shown only when there are any) | degraded |
queues | The queue scheduler isn’t running, or jobs are more than 5 minutes past their time | degraded |
logs | A log write failed in the last 5 minutes | degraded |
/_/ready answers 503 with status: "unready" when the database or
storage is down. Otherwise it answers 200 with status: "ready", or
"degraded" when something works but not fully (the server still serves;
alert on it rather than taking it out of rotation):
{ "data": { "status": "degraded", "checks": { "database": { "status": "ok", "ms": 1 }, "storage": { "status": "ok", "ms": 0 }, "read_workers": { "status": "degraded", "detail": "3 of 4 running; reads on the main thread meanwhile" }, "queues": { "status": "ok" }, "logs": { "status": "ok" } } }}GET /api/health also answers { "data": { "status": "ok", "version": "…" } }
without checking anything.
Kubernetes:
livenessProbe: httpGet: { path: /_/health, port: 8091 }readinessProbe: httpGet: { path: /_/ready, port: 8091 } periodSeconds: 10 timeoutSeconds: 5Docker Compose (the published image includes curl):
healthcheck: test: ["CMD", "curl", "-fsS", "http://localhost:8091/_/ready"] interval: 30s timeout: 5s retries: 3Settings & data live in the DB
Section titled “Settings & data live in the DB”OAuth credentials, SMTP config, rate-limit rules, email templates, auth
feature flags — everything you’d typically configure via env vars in other
backends — live in the vaultbase_settings table and are edited from the
admin UI. So your deployment config is just env vars + the data directory.
To pre-seed settings (e.g. CI seeding OAuth creds), you have two options:
- Restore a backed-up
data.dbthat contains them. - Set them after the first boot with
PATCH /api/v1/admin/settingsand an admin token.
Migration snapshots carry collections only, not settings.
Apply a snapshot on startup
Section titled “Apply a snapshot on startup”For stateless deploys (immutable images, ephemeral containers, fresh dev environments), pass a schema snapshot directly to the binary:
./vaultbase --apply-snapshot=schema.json --snapshot-mode=additive--snapshot-mode=additive(default) — creates collections that don’t exist; leaves existing ones alone.--snapshot-mode=sync— also updates existing collections so they match.- Both equals (
--flag=value) and space (--flag value) forms work. - Idempotent — re-running with the same file is a no-op.
On success, the binary prints applied snapshot: N created, M updated, K unchanged and continues to listen normally.
On failure (missing file, invalid JSON, unknown mode, malformed snapshot, or
any per-collection error), the message is written to stderr and the process
exits with code 1 — the server never starts, so a broken snapshot
won’t silently boot a half-applied DB.
# Typical container entrypoint./vaultbase --apply-snapshot=/etc/vaultbase/schema.json --snapshot-mode=syncShared commits
Section titled “Shared commits”Record creates, updates and deletes that arrive in the same event-loop turn run in one SQLite transaction and commit together, instead of one commit per write. What stays the same:
- A write returns only after the COMMIT that holds it. If that COMMIT fails, every write in it fails; a statement that fails only undoes itself.
- Realtime events, webhooks and after-hooks run after the commit.
- Batches, cascading deletes and schema changes commit any open group first, then run in their own transaction.
The one difference: another request can read a write up to one event-loop
turn before it commits. It only disappears if that COMMIT itself fails (disk
full, I/O error), and then the write’s own request fails too. Set
VAULTBASE_GROUP_COMMIT=0 to commit every write on its own. Shared commits
are off under vaultbase cluster.
WAL checkpoints (copying the write-ahead log back into data.db) run on a
background thread with their own connection, so no request waits for one. If
that thread stops, SQLite’s own checkpointing takes over again. The admin
Monitoring page shows which one is running.
Read worker threads
Section titled “Read worker threads”Record list and get-one requests are served on worker threads, one per spare
CPU core by default (VAULTBASE_READ_WORKERS; 0 keeps everything on one
thread, a number sizes the pool, at most 8 by default). Each worker holds its
own connection to the database and costs roughly 50–100 MB. Writes, auth,
admin, files, realtime and user code stay on the main thread, so there is
still one writer and one set of rate limits and realtime connections.
What a worker sees: a collection or settings change on the main thread at once; which tables a committed write touched, so its list-totals cache keeps the rest; the first read of a collection after start (or after a change to it) is served on the main thread while it refills its collection cache. A worker that dies is dropped and its in-flight requests fail; a forwarded request with no reply within 30 s fails with 503. The service log says how many workers started, and the admin Monitoring page shows them under “read workers · forwarded” with their latencies folded into the request pipeline.
On Linux (since v0.27.0) the read workers also listen on the server’s port, and
the kernel spreads connections over them and the main thread. A worker serves
list and get-one itself and passes every other request to the main thread over
a loopback listener on a random 127.0.0.1 port, with the client’s address, so
trusted proxies and per-client limits work the same; the rate-limit buckets stay
on the main thread. A slow request on a worker (a search, a filter on an
unindexed field) holds up the other requests on that worker’s connections.
VAULTBASE_WORKER_LISTENERS=0 keeps every connection on the main thread, as
before; macOS and Windows don’t spread connections and always do.
Cluster mode (multi-process) — deprecated
Section titled “Cluster mode (multi-process) — deprecated”Read worker threads replace cluster mode: one process now uses every core, with one set of rate limits and realtime connections and cache invalidation that reaches every thread. Cluster mode still works and prints a deprecation line at start; a later release removes it. To run it anyway:
./vaultbase cluster # auto: one worker per CPU coreVAULTBASE_WORKERS=4 ./vaultbase cluster # explicit countEach worker calls Bun.serve({ reusePort: true }) so the kernel
load-balances incoming TCP connections across them — no in-process scheduler,
no shared memory, just N copies of the same binary listening on the same
port. Workers are respawned 1s after a crash; SIGTERM/SIGINT to the
parent triggers a 30s graceful drain.
The same <dataDir> is shared by every worker. SQLite WAL mode handles the
concurrent writes.
Limits — cluster mode is for stateless REST scaling
Section titled “Limits — cluster mode is for stateless REST scaling”Each worker is a separate process with its own memory. In particular:
- Caches are per worker. A collection, hook, route, cron job or queue worker created or changed through one worker isn’t seen by the others until they restart. Make schema and code changes, then restart the service.
- Realtime is per worker (see below).
- Rate limits and realtime connection caps are per worker, so the effective limit is N times the configured one.
- Online restore is disabled (
409). Stop the service, replacedata.dbwith the backup, start it again. - Cron jobs, daily maintenance, the update check and the orphan-file sweep run on worker 0 only; queue jobs, webhook deliveries and scheduled backups are claimed in the database and run on any worker.
Worker 0 prints a warning with these limits at startup.
Verifying which worker answered
Section titled “Verifying which worker answered”/_/health returns the responding worker’s id and pid:
curl http://localhost:8091/_/health# { "data": { "status": "ok", "worker_id": "3", "pid": 41872, "uptime_s": 287 } }Repeat the call a few times — worker_id should rotate. If it doesn’t,
your reverse proxy is pinning to one upstream (sticky sessions, hash-based
load-balancing) and you’re not actually using the cluster.
Realtime caveat — front with sticky sessions or stay single-process
Section titled “Realtime caveat — front with sticky sessions or stay single-process”There is no inter-worker pub/sub yet. Each worker keeps its own subscription map, so a client connected to worker A will not see writes that landed on worker B. All workers share one port and the kernel picks one per connection, so a reverse proxy can’t route realtime to a particular worker. If you rely on realtime, stay single-process: one Bun event loop sustains thousands of open WebSockets — the cluster is a CPU optimization, not a connection-count one.
Cluster mode is deprecated and won’t get cross-worker fan-out (see the roadmap): read worker threads use every core from one process, and realtime there reaches every subscriber.
Self-update
Section titled “Self-update”Update the binary in place from the latest signed GitHub release:
# Interactive — prompts before swapping the binary./vaultbase update
# Non-interactive (cron-friendly)./vaultbase update --yes
# Check only, don't download. Exits 0 if in sync, 1 if newer available../vaultbase update --check
# Pin to a specific version./vaultbase update --version 0.8.0 --yes
# Backwards move (refuses by default)./vaultbase update --version 0.6.0 --allow-downgrade --yesWhat it does:
- Detects platform (
linux-x64,linux-arm64,linux-x64-musl,macos-x64,macos-arm64,windows-x64) - Queries
api.github.com/repos/vaultbase-sh/vaultbase/releases/latest - Compares
vaultbase --versionto the release tag - Downloads the binary,
.sha256,.sig,.pem - Verifies SHA-256 (always — fails closed)
- Verifies cosign signature if
cosignis onPATH(warns if not) - Atomically renames the new binary into place
- Prints “restart vaultbase to apply”
The running process keeps executing the old inode until you restart — no mid-request swap. With systemd:
./vaultbase update --yes && sudo systemctl restart vaultbaseCosign verification
Section titled “Cosign verification”Install cosign to enable cryptographic provenance on every update:
# Linux x64curl -L https://github.com/sigstore/cosign/releases/latest/download/cosign-linux-amd64 \ -o /usr/local/bin/cosign && chmod +x /usr/local/bin/cosign
# Verify it's installedcosign versionWithout cosign, vaultbase update still verifies SHA-256 from the release
manifest but warns that cryptographic verification is skipped. The
--no-verify flag explicitly skips cosign even when installed (SHA-256
remains enforced). Don’t pass it.
Windows
Section titled “Windows”The running .exe is locked while in use. Stop the daemon, run the update,
restart:
sc stop vaultbase./vaultbase.exe update --yessc start vaultbaseAuto-update via cron
Section titled “Auto-update via cron”Cautious deployments: weekly check + apply with restart. Not recommended for production without staging review of release notes first.
# /etc/cron.d/vaultbase-update — every Sunday at 04:13 UTC13 4 * * 0 root /usr/local/bin/vaultbase update --yes --quiet && systemctl restart vaultbase