Skip to content

Deployment Notes

BVE Gateway runs as a single Cloudflare Worker that serves the API (api.bve.me), the admin dashboard (admin.bve.me), and the documentation site (docs.bve.me) via hostname-based routing and the Workers Assets binding.

  • Cloudflare account with Workers and D1 access
  • bun installed
  • wrangler (included as a dev dependency — use bunx wrangler)
Terminal window
# Create the D1 database
bunx wrangler d1 create bve_gateway
# Copy the returned database_id into wrangler.jsonc
# Create the event queue
bunx wrangler queues create bve-gateway-events

After creating the D1 database, update wrangler.jsonc with the returned database_id:

{
"d1_databases": [
{
"binding": "DB",
"database_name": "bve_gateway",
"database_id": "YOUR_DATABASE_ID_HERE"
}
]
}
Terminal window
bun run db:migrate:remote
Terminal window
bunx wrangler secret put FUELIX_API_KEY # Fuelix upstream API key
bunx wrangler secret put ADMIN_API_KEY # Admin key for /admin/* routes
bunx wrangler secret put API_KEY_PEPPER # Random 32+ char string for key hashing
# Optional: alert webhook (see "Webhook notifications" section below)
bunx wrangler secret put WEBHOOK_URL # HTTPS URL to receive rate-limit alert POSTs
bunx wrangler secret put WEBHOOK_SECRET # HMAC-SHA256 signing key for webhook requests

Create the first admin dashboard user so you can log in to https://admin.bve.me after deployment:

Terminal window
# For local development (against local D1):
bun run admin:create
# For production (requires wrangler auth and remote D1):
bun run admin:create --remote

The script prompts interactively for name, email, password, and role (owner or viewer). Run this after applying migrations — it writes directly to D1 and does not require the Worker to be deployed first.

To reset a password later: bun run admin:reset-password --remote

Terminal window
bun run deploy

This command:

  1. Builds the Astro docs site (docs/dist/)
  2. Deploys the Worker with the built assets

The deploy tag is derived from the current worktree. Clean trees use the 7-character Git HEAD tag. Dirty trees use <head>-dirty-<fingerprint> so /health and live audits report the exact artifact provenance instead of pretending the deploy matched clean HEAD. Set BVE_DEPLOY_TAG or DEPLOY_TAG if you need to override the tag explicitly.

The Worker is configured in wrangler.jsonc to respond on three custom domains:

{
"routes": [
{ "pattern": "api.bve.me", "custom_domain": true },
{ "pattern": "admin.bve.me", "custom_domain": true },
{ "pattern": "docs.bve.me", "custom_domain": true }
]
}

Add these domains in the Cloudflare dashboard under Workers & Pages > your Worker > Settings > Custom Domains, or they will be attached automatically on the next deploy if already configured as zones in your account.

The Worker routes based on hostname:

  • api.bve.me → runs the API gateway logic
  • admin.bve.me → serves the admin dashboard UI (email/password login)
  • docs.bve.me → serves the Astro static site via the ASSETS binding
Terminal window
# Copy example local secret file
cp .dev.vars.example .dev.vars
# Edit .dev.vars with real values (never commit this file)
# `.env` is also supported when `.dev.vars` is absent, but `.dev.vars` remains the
# preferred source for full local admin/key-management development.
# Apply migrations locally
bun run db:migrate:local
# Start local dev server
bun run dev

The local server runs at http://localhost:8787.

Configurable via wrangler.jsonc vars (non-secret):

VariableDefaultDescription
FUELIX_BASE_URLhttps://api.fuelix.ai/v1Fuelix upstream base URL
MONTHLY_WORKER_REQUEST_SOFT_CAP8500000Soft cap — returns 429
MONTHLY_WORKER_REQUEST_HARD_CAP9500000Hard cap — returns 503

Secrets (set via wrangler secret put):

SecretRequiredDescription
FUELIX_API_KEYYesFuelix upstream API key
ADMIN_API_KEYYesKey for /admin/* routes
API_KEY_PEPPERYesSHA-256 pepper for client key hashing
WEBHOOK_URLNoHTTPS URL to receive rate-limit alert notifications
WEBHOOK_SECRETNoHMAC-SHA256 signing key for webhook requests

When WEBHOOK_URL is set as a Worker secret, the gateway’s queue consumer (handleQueue) POSTs a structured JSON notification to that URL on every rate_limit_exceeded alert event.

Terminal window
bunx wrangler secret put WEBHOOK_URL # e.g., https://hooks.example.com/bve-alerts

The URL must use https://. Requests to http:// URLs are rejected to prevent audit event data (key IDs, rate-limit reasons) from being transmitted in plaintext. Invalid URLs are also rejected and logged.

{
"gateway": "bve-gateway",
"event": "rate_limit_exceeded",
"keyId": "key-uuid",
"reason": "Rate limit exceeded: requests per minute",
"timestamp": "2026-05-22T14:00:00.000Z"
}

To verify that webhook requests are genuinely from your BVE Gateway (and not forged by a third party that discovers your endpoint URL), set a WEBHOOK_SECRET:

Terminal window
bunx wrangler secret put WEBHOOK_SECRET # any strong random string

When WEBHOOK_SECRET is set, every webhook POST includes an X-BVE-Signature header:

X-BVE-Signature: sha256=<64-character-hex-hmac>

The signature is HMAC-SHA256(WEBHOOK_SECRET, raw-JSON-body) — the same convention used by GitHub webhooks.

Verify in Node.js / TypeScript:

import crypto from 'crypto';
function verifyBveSignature(
secret: string,
rawBody: string,
signatureHeader: string
): boolean {
const expected = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(rawBody, 'utf8')
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(expected, 'utf8'),
Buffer.from(signatureHeader, 'utf8')
);
}
// In your webhook handler:
const sig = req.headers['x-bve-signature'] as string;
const body = req.rawBody; // raw string before JSON.parse
if (!verifyBveSignature(process.env.WEBHOOK_SECRET!, body, sig)) {
return res.status(401).send('Invalid signature');
}

BVE Gateway includes a daily cron trigger configured in wrangler.jsonc:

0 3 * * * → 03:00 UTC every day

The scheduled handler (src/handlers/scheduled.ts) runs three D1 cleanup queries in a single batch:

TableRetentionWhat is deleted
request_logs_sampled90 daysSampled request log rows older than 90 days
admin_login_attempts30 daysLogin attempt rows older than 30 days (brute-force rate-limit history)
admin_sessionsRows where expires_at < now (expired sessions)

The cron run emits a structured scheduled_cleanup log line visible in wrangler tail and Workers Logs:

{
"event": "scheduled_cleanup",
"cron": "0 3 * * *",
"pruned": {
"request_logs_sampled": 142,
"admin_login_attempts": 0,
"admin_sessions": 3
}
}

The cron trigger is registered automatically when the Worker is deployed — no manual step is required. You can verify it is active in the Cloudflare dashboard under Workers & Pages → bve-gateway → Settings → Triggers → Cron Triggers.

Terminal window
# Pull latest code
git pull
# Run migrations if schema changed
bun run db:generate
bun run db:migrate:remote
# Deploy
bun run deploy
Terminal window
bunx wrangler tail

Every request emits one structured JSON log line. Example (streaming chat request):

{
"level": "info",
"type": "request",
"requestId": "550e8400-e29b-41d4-a716-446655440000",
"method": "POST",
"path": "/v1/chat/completions",
"status": 200,
"latencyMs": 4321,
"stream": true,
"upstreamLatencyMs": 312,
"keyId": "k-abc123",
"keyName": "production-key",
"model": "gpt-4o",
"upstreamStatus": 200,
"upstreamRequestId": "req_fuelix_abc",
"cfRay": "7abc123def456-SJC",
"ip": "203.0.113.1",
"country": "US",
"colo": "SJC",
"ua": "python-openai/1.0.0"
}
FieldTypeWhen presentDescription
levelstringAlwaysinfo (2xx/3xx), warn (4xx), error (5xx)
typestringAlwaysAlways "request"
requestIdstringAlwaysUUID assigned to this request; forwarded as X-Request-Id
methodstringAlwaysHTTP method
pathstringAlwaysURL path
statusnumberAlwaysResponse HTTP status code
latencyMsnumberAlwaysTotal wall-clock time from request start to response end
slowbooleanIf > 5000 mstrue when latencyMs > 5000; omitted otherwise
streambooleanIf streamingtrue for SSE/NDJSON streaming responses (chat, messages, responses). When present, high latencyMs is expected — the stream is open until the client disconnects. Token counts are absent for streaming.
upstreamLatencyMsnumberIf proxiedTime from start of upstream fetch to response headers; absent for middleware rejections
keyIdstringIf authenticatedAPI key ID
keyNamestringIf authenticatedHuman-readable API key name
modelstringIf knownModel name from request body (set before proxying)
upstreamStatusnumberIf proxiedHTTP status from Fuelix
upstreamRequestIdstringIf presentFuelix’s own request ID (x-request-id or request-id response header)
promptTokensnumberIf bufferedPrompt token count (non-streaming buffered JSON responses only)
completionTokensnumberIf bufferedCompletion token count (non-streaming buffered JSON responses only)
totalTokensnumberIf bufferedpromptTokens + completionTokens
errorCodestringIf errorGateway error code (e.g. rate_limit_exceeded, model_not_allowed)
cfRaystringIf availableCloudflare CF-Ray header for edge infrastructure correlation
ipstringIf availableClient IP (CF-Connecting-IP)
countrystringIf availableCountry code from Cloudflare cf.country
colostringIf availableCloudflare edge colo code
uastringIf availableUser-Agent (truncated to 200 characters)

The level field maps to the log method: infoconsole.log, warnconsole.warn, errorconsole.error. Filter by level in Cloudflare Workers Logs or Logpush without JSON post-processing.

CommandDescription
bun run devLocal development server
bun run deployBuild docs + deploy Worker
bun run tailStream live Worker logs
bun run typecheckTypeScript type check
bun test — ❌Use bun run test instead (Cloudflare pool)
bun run testRun test suite
bun run db:generateGenerate Drizzle migrations
bun run db:migrate:localApply migrations locally
bun run db:migrate:remoteApply migrations to production D1