Self-hosting

The hosted process is one Node.js service. The runtime contract is a Postgres 16 URL, an https origin you control, and a platform key (raw on a laptop; KMS-wrapped in production). The reference deployment is one Fly Machine per plane, Neon Postgres, Cloudflare DNS and WAF in front, and AWS KMS holding the key that wraps the platform key. Any other Postgres 16 and any non-platform-default https origin work the same. The code is MIT licensed at github.com/naffis/botpasses.

For a laptop hosted kernel (loopback, sqlite-hosted, OTP printed in the terminal) run npm run hosted:dev. That sets VAULT_DEPLOY_PLANE=dev and does not inherit a leftover DATABASE_URL. Run one process per sqlite file; a second writer can get SQLITE_BUSY. Do not set plane dev on Fly.

The operational runbooks are in the repository under docs/ops (cutover, KEK rotation, restore).

Paste the self-host prompt into an ops agent for help with the configuration on this page and the runbooks in docs/ops. Do not paste secret values into chat.

Copy-paste prompt

Help me deploy a self-hosted Botpasses plane from https://github.com/naffis/botpasses. Goal: a private grant-vault my agents hit at our origin (not necessarily botpasses.com). Follow docs/self-hosting and docs/ops in the repo. Do not invent secret values; ask me to set them in the secret store.

Reference shape (adapt to our cloud if we are not on Fly):
- One Node process: VAULT_MODE=hosted vault serve (one Machine per plane; in-memory enroll/rate-limit state).
- Postgres 16 (any vendor): separate database per plane; pooled DATABASE_URL + direct DATABASE_URL_DIRECT.
- Edge DNS/WAF (Cloudflare or ours) with SSL full strict.
- AWS KMS (or approved KMS) wrapping VAULT_KEK_WRAPPED; VAULT_KMS_APP_ID or FLY_APP_NAME; Fly OIDC or equivalent for AWS_ROLE_ARN.
- Email provider for codes (Resend pattern: RESEND_API_KEY + VAULT_EMAIL_FROM).
- Optional: R2/S3 encrypted backups, Sentry without values.
- Laptop hosted kernel is npm run hosted:dev (VAULT_DEPLOY_PLANE=dev, loopback, sqlite). That plane is refused when FLY_APP_NAME is set.

Required config checklist (confirm each is set in secrets, never paste into chat):
DATABASE_URL (Postgres URL), DATABASE_URL_DIRECT, VAULT_PUBLIC_URL (our https origin), VAULT_DEPLOY_PLANE (staging|production), VAULT_KEK_WRAPPED, VAULT_KMS_KEY_ID, VAULT_KMS_APP_ID or FLY_APP_NAME, AWS_ROLE_ARN, VAULT_KEK_REQUIRE_KMS=1 after cutover, VAULT_SESSION_SECRET (>=32 bytes), VAULT_OIDC_PRIVATE_JWK, VAULT_APPROVAL_HMAC (64 hex), bootstrap token pair only for break-glass window, VAULT_TRUST_PROXY as appropriate.

Build:
npm ci
npm --prefix site ci && npm --prefix site run build
VAULT_MODE=hosted VAULT_BIND_HOST=0.0.0.0 PORT=8788 npx vault serve
Exit 78 means config is wrong; fix from the self-hosting table.

After /health and /ready pass:
1. Create the first operator account on our VAULT_PUBLIC_URL (email code + TOTP).
2. Issue a model/agent token or complete OAuth for our MCP clients against https://<our-origin>/mcp.
3. Give developers the hosted agent bootstrap prompt, with every botpasses.com URL replaced by our origin (including /connect/callback on provider apps).
4. Write a short internal runbook: who holds KMS, how to revoke an agent, how to rotate KEK (vault kek-rotate), backup restore pointer under docs/ops.

Constraints: do not scale to two Machines without moving in-memory state to the database. Do not branch production Postgres from staging. Never log or return credential values.

Components

ComponentRole
Fly.io appRuns VAULT_MODE=hosted vault serve. One Machine. Health checks on /health and /ready
Neon PostgresAll hosted data. Use a separate project per plane; do not branch production from staging. Pooled URL for the app, direct URL for backups
CloudflareOrange-cloud DNS for the apex and staging. host, WAF, SSL Full (strict). www is a 301 to the apex
AWS KMSA customer managed key the Fly Machine can call through Fly OIDC. It unwraps the platform key at boot
ResendEmail codes and approval notices from a verified sending domain
Cloudflare R2Encrypted nightly pg_dump from a GitHub Actions job
Sentry (optional)Error reports. Values are never included

Build and run

npm ci
npm --prefix site ci && npm --prefix site run build   # the marketing site must exist at boot
VAULT_MODE=hosted VAULT_BIND_HOST=0.0.0.0 PORT=8788 npx vault serve

Hosted mode reads its listen address from VAULT_BIND_HOST and PORT (or VAULT_PORT); the --host and --port flags belong to the local vault serve only. The process exits with code 78 when configuration is wrong: npm run hosted without VAULT_MODE=hosted, a short session secret, a missing RS256 private JWK, a VAULT_OIDC_PREVIOUS_JWK that is not a private RS256 JWK, RESEND_API_KEY without VAULT_EMAIL_FROM, VAULT_HOME set in hosted mode, or a missing site/dist/index.html. The Dockerfile in the repository builds the site and installs the CLI in one image.

Required secrets

SecretPurpose
DATABASE_URLPostgres 16 URL (any vendor). The reference stack uses a Neon pooled (-pooler) host
DATABASE_URL_DIRECTDirect Postgres URL for backups and migrations
VAULT_PUBLIC_URLThe origin this plane serves. First-party hosts must match the plane. A custom https origin is allowed on self-hosted staging/production. Loopback is plane dev only
VAULT_DEPLOY_PLANEstaging, production, or dev, required. Staging refuses vault environment production. Unset is exit 78. dev is the laptop hosted kernel and is refused when FLY_APP_NAME is set
VAULT_KEK_WRAPPEDThe platform key, wrapped by KMS. Produced by vault kek-wrap
VAULT_KMS_KEY_IDThe KMS key id or ARN
VAULT_KMS_APP_IDOptional. KMS EncryptionContext app when FLY_APP_NAME is unset
AWS_ROLE_ARNRole the Machine assumes through Fly OIDC; it needs kms:Decrypt on that key
VAULT_KEK_REQUIRE_KMSSet to 1 after the wrapped key is confirmed. Refuses to boot on the raw key
VAULT_KEKRaw platform key. Pre-cutover fallback only; unset it after VAULT_KEK_REQUIRE_KMS=1
VAULT_SESSION_SECRET32 bytes or more. Signs sessions and CSRF tokens
VAULT_OIDC_PRIVATE_JWKRS256 private JWK that signs OAuth access tokens
VAULT_OIDC_PREVIOUS_JWKOnly during a key rotation: the JWK being retired. Published in JWKS and still verifies the tokens it signed; never signs new ones
VAULT_APPROVAL_HMACSigns email approval links. 64 hex characters (32 bytes); any other shape is exit 78
VAULT_BOOTSTRAP_TOKEN32 characters or more. Break-glass operator token; keep it offline. Every use is logged as auth_bootstrap_used with a token hash
VAULT_BOOTSTRAP_ALLOW_PLANESet to 1 only for the break-glass window. Staging and production ignore VAULT_BOOTSTRAP_TOKEN unless this is 1; unset both afterwards
VAULT_TRUST_PROXYSet to 1 when a proxy you control (nginx, Caddy) sits in front and appends X-Forwarded-For; only that last hop is then trusted. Fly-Client-IP is trusted only on Fly (FLY_APP_NAME), which also implies this setting. Otherwise the socket peer is the caller’s address for rate limits and logs
RESEND_API_KEYSending-access key scoped to your domain
VAULT_EMAIL_FROMFor example Botpasses <[email protected]>. Required when RESEND_API_KEY is set
SENTRY_DSNOptional; a plane without it logs sentry_dsn_missing at boot
VAULT_TRUSTED_PROXY_CIDRSOptional. Comma-separated CIDRs of the CDN in front of the plane. CF-Connecting-IP is read for rate limits only when the connecting address is inside them. Unset means Cloudflare’s published ranges; empty means the header is never read
VAULT_BIND_HOSTOptional. Listen address. Default 127.0.0.1 on plane dev, 0.0.0.0 on staging/production
PORTOptional. Listen port, default 8788; Fly sets it. VAULT_PORT is read when PORT is unset
VAULT_SITE_ROOTOptional. Directory of the built Astro site, default ./site/dist; boot exits 78 when its index.html is missing

Backup job secrets (GitHub Actions, not Fly): DATABASE_URL_DIRECT, BACKUP_KEY (32-byte hex, must not be the vault key), R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET. The job fails closed when R2 is unset.

Key hierarchy

  1. The AWS KMS key (non-exportable) unwraps VAULT_KEK_WRAPPED once at boot.
  2. The platform key in process memory wraps one data key per organisation.
  3. The organisation key encrypts each credential with AES-256-GCM, bound to the organisation id.

A database dump alone cannot decrypt anything. Rotation: vault kek-rotate re-wraps the organisation keys under a new platform key.

DNS

Orange-cloud A and AAAA records for the apex and the staging host, plus a grey-cloud _fly-ownership TXT record for Fly’s certificate. www redirects to the apex at Cloudflare.

Limits of the reference deployment

Some state (pending authenticator enrollments, per-IP limiters) lives in process memory, so the design is one Machine per plane. Do not scale to two Machines without moving that state to the database.