Skip to content

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.

  • 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 when xz is available and verifies the decompressed binary against the same .sha256 / signature.
  • An empty data directory — Vaultbase creates the DB and folders on first run

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:

Terminal window
curl -fsSL https://get.vaultbase.dev | sudo sh -s -- --verify-sig

On Windows, the PowerShell installer takes -VerifySig:

Terminal window
& ([scriptblock]::Create((irm https://get.vaultbase.dev/install.ps1))) -VerifySig

Manual verify with cosign:

Terminal window
TAG=v0.1.6
BIN=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.

Terminal window
# On the server
useradd --system --home-dir /srv/vaultbase --shell /usr/sbin/nologin vaultbase
mkdir -p /srv/vaultbase
chown vaultbase:vaultbase /srv/vaultbase
cp vaultbase-linux-x64 /usr/local/bin/vaultbase
chmod +x /usr/local/bin/vaultbase
# systemd unit at /etc/systemd/system/vaultbase.service
[Unit]
Description=Vaultbase
After=network.target
[Service]
Type=simple
User=vaultbase
WorkingDirectory=/srv/vaultbase
Environment=VAULTBASE_DATA_DIR=/srv/vaultbase/data
Environment=VAULTBASE_PORT=8091
ExecStart=/usr/local/bin/vaultbase
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
# Then
systemctl daemon-reload
systemctl enable --now vaultbase

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

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.

Terminal window
# Once: generate the two keys and keep this file
printf 'VAULTBASE_JWT_SECRET=%s\nVAULTBASE_ENCRYPTION_KEY=%s\n' \
"$(openssl rand -base64 32)" "$(openssl rand -base64 32)" > vaultbase.env
chmod 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.1
docker 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_WORKERS to what the machine can spare (cores minus two; 0 on 1–2 vCPU).
  • Health checks. The image’s HEALTHCHECK calls /_/health (the process answers). Point a load balancer or orchestrator’s readiness probe at /_/ready.
  • Pin a version in production; latest moves with every release.

deploy/docker/docker-compose.yml runs the image with Caddy in front, which gets a certificate for your domain:

Terminal window
git clone https://github.com/vaultbase-sh/vaultbase && cd vaultbase
cp deploy/docker/.env.example .env # set DOMAIN, generate both secrets
docker compose -f deploy/docker/docker-compose.yml --env-file .env up -d
docker 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).

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.

Deploy to Render

HostHowNotes
RenderDeploy 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.iofly launch --config deploy/fly/fly.toml --copy-config --no-deploy, then the commands in deploy/fly/fly.tomlOne 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.
RailwayThe 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.

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.

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

VarRecommended
VAULTBASE_JWT_SECRET32+ bytes from openssl rand -base64 32. Persist; rotating invalidates every existing JWT.
VAULTBASE_ENCRYPTION_KEY32 bytes from openssl rand -base64 32. Required for encrypted fields. Don’t lose it — losing it makes encrypted values unreadable.
VAULTBASE_DATA_DIRA persistent volume — never /tmp.
VAULTBASE_PORTIf proxying, you can keep :8091 and not expose it publicly.
VAULTBASE_TRUSTED_PROXIESCIDR 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_HOST127.0.0.1 when a reverse proxy on the same host is the only way in. Default: every interface, IPv4 and IPv6.
VAULTBASE_SETUP_KEYKey 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.

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.

The binary doubles as a setup tool. Run it once on the host before binding to a public port:

Terminal window
./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.

Two probes, both public and outside the rate limiter:

  • GET /_/health — liveness. Answers 200 as 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.
CheckWhat it doesFails as
databaseA round trip to the database within 2 sdown → unready
storageA lookup in the configured file storage within 3 sdown → unready
read_workersFewer read workers running than configured (shown only when there are any)degraded
queuesThe queue scheduler isn’t running, or jobs are more than 5 minutes past their timedegraded
logsA log write failed in the last 5 minutesdegraded

/_/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: 5

Docker Compose (the published image includes curl):

healthcheck:
test: ["CMD", "curl", "-fsS", "http://localhost:8091/_/ready"]
interval: 30s
timeout: 5s
retries: 3

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:

  1. Restore a backed-up data.db that contains them.
  2. Set them after the first boot with PATCH /api/v1/admin/settings and an admin token.

Migration snapshots carry collections only, not settings.

For stateless deploys (immutable images, ephemeral containers, fresh dev environments), pass a schema snapshot directly to the binary:

Terminal window
./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.

Terminal window
# Typical container entrypoint
./vaultbase --apply-snapshot=/etc/vaultbase/schema.json --snapshot-mode=sync

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.

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:

Terminal window
./vaultbase cluster # auto: one worker per CPU core
VAULTBASE_WORKERS=4 ./vaultbase cluster # explicit count

Each 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, replace data.db with 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.

/_/health returns the responding worker’s id and pid:

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

Update the binary in place from the latest signed GitHub release:

Terminal window
# 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 --yes

What it does:

  1. Detects platform (linux-x64, linux-arm64, linux-x64-musl, macos-x64, macos-arm64, windows-x64)
  2. Queries api.github.com/repos/vaultbase-sh/vaultbase/releases/latest
  3. Compares vaultbase --version to the release tag
  4. Downloads the binary, .sha256, .sig, .pem
  5. Verifies SHA-256 (always — fails closed)
  6. Verifies cosign signature if cosign is on PATH (warns if not)
  7. Atomically renames the new binary into place
  8. Prints “restart vaultbase to apply”

The running process keeps executing the old inode until you restart — no mid-request swap. With systemd:

Terminal window
./vaultbase update --yes && sudo systemctl restart vaultbase

Install cosign to enable cryptographic provenance on every update:

Terminal window
# Linux x64
curl -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 installed
cosign version

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

The running .exe is locked while in use. Stop the daemon, run the update, restart:

Terminal window
sc stop vaultbase
./vaultbase.exe update --yes
sc start vaultbase

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 UTC
13 4 * * 0 root /usr/local/bin/vaultbase update --yes --quiet && systemctl restart vaultbase