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.
Prerequisites
Section titled “Prerequisites”- Cloudflare account with Workers and D1 access
buninstalledwrangler(included as a dev dependency — usebunx wrangler)
First-time setup
Section titled “First-time setup”1. Create Cloudflare resources
Section titled “1. Create Cloudflare resources”# Create the D1 databasebunx wrangler d1 create bve_gateway# Copy the returned database_id into wrangler.jsonc
# Create the event queuebunx wrangler queues create bve-gateway-events2. Update wrangler.jsonc
Section titled “2. Update wrangler.jsonc”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" } ]}3. Apply database migrations
Section titled “3. Apply database migrations”bun run db:migrate:remote4. Set secrets
Section titled “4. Set secrets”bunx wrangler secret put FUELIX_API_KEY # Fuelix upstream API keybunx wrangler secret put ADMIN_API_KEY # Admin key for /admin/* routesbunx 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 POSTsbunx wrangler secret put WEBHOOK_SECRET # HMAC-SHA256 signing key for webhook requests5. Create admin dashboard user
Section titled “5. Create admin dashboard user”Create the first admin dashboard user so you can log in to https://admin.bve.me after deployment:
# For local development (against local D1):bun run admin:create
# For production (requires wrangler auth and remote D1):bun run admin:create --remoteThe 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
6. Deploy
Section titled “6. Deploy”bun run deployThis command:
- Builds the Astro docs site (
docs/dist/) - 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.
Custom domains
Section titled “Custom domains”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 logicadmin.bve.me→ serves the admin dashboard UI (email/password login)docs.bve.me→ serves the Astro static site via theASSETSbinding
Local development
Section titled “Local development”# Copy example local secret filecp .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 locallybun run db:migrate:local
# Start local dev serverbun run devThe local server runs at http://localhost:8787.
Environment variables
Section titled “Environment variables”Configurable via wrangler.jsonc vars (non-secret):
| Variable | Default | Description |
|---|---|---|
FUELIX_BASE_URL | https://api.fuelix.ai/v1 | Fuelix upstream base URL |
MONTHLY_WORKER_REQUEST_SOFT_CAP | 8500000 | Soft cap — returns 429 |
MONTHLY_WORKER_REQUEST_HARD_CAP | 9500000 | Hard cap — returns 503 |
Secrets (set via wrangler secret put):
| Secret | Required | Description |
|---|---|---|
FUELIX_API_KEY | Yes | Fuelix upstream API key |
ADMIN_API_KEY | Yes | Key for /admin/* routes |
API_KEY_PEPPER | Yes | SHA-256 pepper for client key hashing |
WEBHOOK_URL | No | HTTPS URL to receive rate-limit alert notifications |
WEBHOOK_SECRET | No | HMAC-SHA256 signing key for webhook requests |
Webhook notifications
Section titled “Webhook notifications”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.
bunx wrangler secret put WEBHOOK_URL # e.g., https://hooks.example.com/bve-alertsThe 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.
Webhook payload
Section titled “Webhook payload”{ "gateway": "bve-gateway", "event": "rate_limit_exceeded", "keyId": "key-uuid", "reason": "Rate limit exceeded: requests per minute", "timestamp": "2026-05-22T14:00:00.000Z"}Webhook signing
Section titled “Webhook signing”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:
bunx wrangler secret put WEBHOOK_SECRET # any strong random stringWhen 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.parseif (!verifyBveSignature(process.env.WEBHOOK_SECRET!, body, sig)) { return res.status(401).send('Invalid signature');}Scheduled maintenance
Section titled “Scheduled maintenance”BVE Gateway includes a daily cron trigger configured in wrangler.jsonc:
0 3 * * * → 03:00 UTC every dayThe scheduled handler (src/handlers/scheduled.ts) runs three D1 cleanup queries in a single batch:
| Table | Retention | What is deleted |
|---|---|---|
request_logs_sampled | 90 days | Sampled request log rows older than 90 days |
admin_login_attempts | 30 days | Login attempt rows older than 30 days (brute-force rate-limit history) |
admin_sessions | — | Rows 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.
Update and redeploy
Section titled “Update and redeploy”# Pull latest codegit pull
# Run migrations if schema changedbun run db:generatebun run db:migrate:remote
# Deploybun run deployView logs
Section titled “View logs”bunx wrangler tailEvery 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"}Structured log fields
Section titled “Structured log fields”| Field | Type | When present | Description |
|---|---|---|---|
level | string | Always | info (2xx/3xx), warn (4xx), error (5xx) |
type | string | Always | Always "request" |
requestId | string | Always | UUID assigned to this request; forwarded as X-Request-Id |
method | string | Always | HTTP method |
path | string | Always | URL path |
status | number | Always | Response HTTP status code |
latencyMs | number | Always | Total wall-clock time from request start to response end |
slow | boolean | If > 5000 ms | true when latencyMs > 5000; omitted otherwise |
stream | boolean | If streaming | true 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. |
upstreamLatencyMs | number | If proxied | Time from start of upstream fetch to response headers; absent for middleware rejections |
keyId | string | If authenticated | API key ID |
keyName | string | If authenticated | Human-readable API key name |
model | string | If known | Model name from request body (set before proxying) |
upstreamStatus | number | If proxied | HTTP status from Fuelix |
upstreamRequestId | string | If present | Fuelix’s own request ID (x-request-id or request-id response header) |
promptTokens | number | If buffered | Prompt token count (non-streaming buffered JSON responses only) |
completionTokens | number | If buffered | Completion token count (non-streaming buffered JSON responses only) |
totalTokens | number | If buffered | promptTokens + completionTokens |
errorCode | string | If error | Gateway error code (e.g. rate_limit_exceeded, model_not_allowed) |
cfRay | string | If available | Cloudflare CF-Ray header for edge infrastructure correlation |
ip | string | If available | Client IP (CF-Connecting-IP) |
country | string | If available | Country code from Cloudflare cf.country |
colo | string | If available | Cloudflare edge colo code |
ua | string | If available | User-Agent (truncated to 200 characters) |
The level field maps to the log method: info → console.log, warn → console.warn, error → console.error. Filter by level in Cloudflare Workers Logs or Logpush without JSON post-processing.
Useful commands
Section titled “Useful commands”| Command | Description |
|---|---|
bun run dev | Local development server |
bun run deploy | Build docs + deploy Worker |
bun run tail | Stream live Worker logs |
bun run typecheck | TypeScript type check |
bun test — ❌ | Use bun run test instead (Cloudflare pool) |
bun run test | Run test suite |
bun run db:generate | Generate Drizzle migrations |
bun run db:migrate:local | Apply migrations locally |
bun run db:migrate:remote | Apply migrations to production D1 |