Skip to content

Deployment

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.)
  • 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 | sh -s -- --verify-sig

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
mkdir -p /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/.

# Dockerfile
FROM debian:bookworm-slim
COPY releases/vaultbase-linux-x64 /usr/local/bin/vaultbase
RUN chmod +x /usr/local/bin/vaultbase
ENV VAULTBASE_DATA_DIR=/data
ENV VAULTBASE_PORT=8091
EXPOSE 8091
VOLUME ["/data"]
CMD ["vaultbase"]
Terminal window
docker build -t vaultbase .
docker run -d --name vaultbase \
-p 8091:8091 \
-v vaultbase-data:/data \
-e VAULTBASE_JWT_SECRET="$(openssl rand -base64 32)" \
vaultbase

docker-compose.yml:

services:
vaultbase:
build: .
restart: unless-stopped
ports:
- "8091:8091"
volumes:
- vaultbase-data:/data
environment:
VAULTBASE_DATA_DIR: /data
VAULTBASE_JWT_SECRET: ${VAULTBASE_JWT_SECRET}
VAULTBASE_ENCRYPTION_KEY: ${VAULTBASE_ENCRYPTION_KEY}
volumes:
vaultbase-data:

The linux-x64-musl build is the right pick for Alpine-based images.

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.

# Caddyfile
api.example.com {
reverse_proxy localhost:8091 {
header_up X-Forwarded-For {remote_host}
transport http {
versions h1 h2
}
}
}

Caddy handles TLS automatically.

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_read_timeout 86400; # keep WS open
}
location / {
proxy_pass http://127.0.0.1:8091;
proxy_set_header X-Forwarded-For $remote_addr;
}
}
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_SETUP_KEYOptional first-boot guard. When set, POST /api/v1/auth/admin/setup requires the same value in an X-Setup-Key header. Use it to close the race where an attacker reaches /setup before you do on a public IP, then unset it after seeding the first admin.

Auto-generated values land in <dataDir>/.secret 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.

Set VAULTBASE_SETUP_KEY to a high-entropy random string before first boot:

Terminal window
export VAULTBASE_SETUP_KEY="$(openssl rand -hex 32)"
./vaultbase

The browser wizard at /_/setup will refuse to create the admin without an X-Setup-Key header carrying the same value. Curl the create endpoint yourself, or paste the key into the wizard prompt.

Once the first admin exists, unset the env var and restart — the gate is only useful while vaultbase_admin is empty.

GET /api/v1/health
→ { "data": { "status": "ok" } }

Use this for k8s liveness/readiness or load-balancer health.

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 three options:

  1. Apply a migration snapshot on startup that includes the settings.
  2. Restore a backed-up data.db that contains them.
  3. Pass a snapshot file at startup via the CLI flag (next section).

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

The default ./vaultbase runs single-process — one event loop, ~one CPU core, fine for most workloads. To use more cores, run the bundled cluster orchestrator instead:

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; per-collection prepared-statement caches are per-worker (no cross-process invalidation needed since DDL goes through the records API on every worker before the next read).

/_/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. Two options:

  1. Stay single-process. A single Bun event loop sustains thousands of open WebSockets — the cluster is a CPU optimization, not a connection- count one.
  2. Pin the WS path to one worker. Configure your reverse proxy to send /realtime (and the SSE GET /api/v1/realtime) to a single upstream (e.g. nginx ip_hash on the realtime location), and let the rest of the API fan out across workers.

Cross-worker realtime fan-out is on the roadmap; treat the current behavior as a known limit, not a bug.

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