A production-grade backend foundation built with Bun, TypeScript, Hono and the Cloudflare Workers ecosystem (D1, KV, R2, Queues, Cron Triggers). Designed for a small engineering team to take a real SaaS product from zero to production and scale over time.
This is not a CRUD demo. It ships with:
- Versioned REST API (
/api/v1) with OpenAPI docs generated from route definitions - Auth module: JWT access tokens, rotating refresh tokens, API keys (
nb_live_/nb_test_) - Users + Organizations (multi-tenant authorization model:
user → organization → role) - File uploads to R2 with ownership checks and content-type allowlists
- Inbound webhooks: HMAC signature verification, replay protection, idempotent processing
- Background jobs via Cloudflare Queues (with DLQ) and Cron-based cleanup
- Structured JSON logging, request correlation ids, rate limiting, security headers, CORS allowlist, request-size caps, idempotency-key support for writes
HTTP request
→ env validation → request id → security headers → CORS → access log
→ rate limit (KV fixed window)
→ auth middleware (JWT | API key) → authorization (roles/scopes)
→ validation (Zod) → thin handler → service → repository → D1/R2/KV/Queue
→ response envelope / global error handler
Layer responsibilities:
| Layer | Responsibility |
|---|---|
| Route | OpenAPI definition, validation wiring, HTTP concerns only |
| Controller | Folded into thin handlers (see note below) |
| Service | Business logic, orchestration, transactions |
| Repository | Data access exclusively (Drizzle queries) |
Note on controllers:
@hono/zod-openapicouples validation, response typing and the handler in one place. Adding a separate controller file would create pass-through-only modules, so controllers are folded into 5-line route handlers; all business logic still lives in services and all SQL lives in repositories.
Key decisions:
- D1 as primary database — SQLite at the edge, no connection pooling needed at any scale
you'll hit early; Drizzle provides typed schema/migrations. Swap to Neon/Supabase/Turso by
replacing
src/db/index.ts+ repositories (the rest of the codebase never touches SQL drivers). - JWT via Web Crypto (HS256), passwords via PBKDF2 — no Node-only crypto dependencies; runs identically in Workers and Bun.
- Rate limiting: KV fixed window — eventual consistency means limits can be slightly exceeded
under high concurrency; fine for abuse protection. For hard quotas, replace
KvRateLimiterwith a Durable Object implementation behind the sameRateLimiterinterface. - Durable Objects intentionally not used — architecture supports adding them later.
- Email delivery is queue-backed with pluggable providers (
consoleby default, Resend included); add your provider account before relying on it in production.
| Concern | Choice |
|---|---|
| Runtime/PM | Bun |
| Language | TypeScript (strict, noUncheckedIndexedAccess) |
| Framework | Hono + @hono/zod-openapi |
| Validation | Zod |
| Database | Cloudflare D1 + Drizzle ORM |
| Cache/Limits | Cloudflare KV |
| Storage | Cloudflare R2 behind a StorageService interface |
| Jobs | Cloudflare Queues (+ DLQ) & Cron Triggers |
| Lint/Format | Biome |
| Tests | bun test (unit / integration / e2e) |
| CI/CD | GitHub Actions |
- Bun ≥ 1.1
- A Cloudflare account (free tier works locally; paid for queues/cron in production)
wrangler(bundled as dev dependency)
git clone <your-repo-url> && cd api
bun install
cp .dev.vars.example .dev.vars # fill in local secrets| Variable | Where | Required | Notes |
|---|---|---|---|
ENVIRONMENT |
wrangler [vars] |
yes | development | staging | production |
LOG_LEVEL |
wrangler [vars] |
yes | debug | info | warn | error |
ALLOWED_ORIGINS |
wrangler [vars] |
yes | Comma-separated CORS allowlist (no wildcard) |
JWT_SECRET |
wrangler secret |
yes | ≥ 32 chars; openssl rand -hex 32 |
TURNSTILE_SECRET_KEY |
wrangler secret |
optional | Enables server-side Turnstile verification |
EMAIL_PROVIDER |
wrangler [vars] |
optional | console (default) or resend |
RESEND_API_KEY |
wrangler secret |
optional | Needed when EMAIL_PROVIDER=resend |
Environment validation (src/config/env.ts, Zod) fails fast per isolate when critical
configuration is missing or malformed. Secrets are never committed — see .env.example.
bun run dev # wrangler dev (D1/KV/R2/Queues all simulated locally)
bun run typecheck
bun run lint # biome check
bun run format
bun test # unit + integration + e2e
bun run test:watchQueue consumers run automatically under wrangler dev; cron triggers can be tested with
wrangler dev --test-scheduled then curl http://localhost:8787/__scheduled?cron=0+3+*+*+*.
bun run db:generate # drizzle-kit generate → migrations/*.sql (review the diff!)
bun run db:migrate:local # apply to local D1
bun run db:migrate:staging # apply to remote staging D1
bun run db:migrate:production
bun run db:studio # browse schema/data
bun run db:seed:local # seed dev dataSafety rules:
db:migrate:*applies forward-only migrations frommigrations/.- Never hand-edit an applied migration file; generate a new one instead.
- Destructive changes (drop column/table): generate the migration, review the SQL diff in the PR,
verify a backup exists (
wrangler d1 time-travel info), and coordinate the deploy. - Production deploys run migrations before the worker deploy; a failed migration aborts deploy.
bunx wrangler login
# Dev/staging/production databases & namespaces — update IDs in wrangler.toml:
wrangler d1 create api-dev && wrangler d1 create api-staging && wrangler d1 create api-production
wrangler kv namespace create CACHE # repeat per environment
wrangler r2 bucket create api-dev-uploads # repeat per environment
wrangler queues create api-dev-jobs # repeat per environment (+ DLQs)
# Secrets (per environment):
wrangler secret put JWT_SECRET --env staging
wrangler secret put JWT_SECRET --env productionTests run against the real Hono app with a minimal D1-compatible shim over bun:sqlite,
so repositories execute real SQL against the real migrations — no query mocks.
tests/
unit/ # crypto, pagination cursors, errors, rate limiter, webhook verifier, logger redaction
integration/ # (reserved) repository/service-level DB tests using tests/helpers/env.ts
e2e/ # full API flows: auth lifecycle, rotation reuse, RBAC, API keys, validation, headers/CORS
helpers/ # D1 shim + KV/R2/Queue stubs
bun run build # dry-run bundle validation
bun run deploy:staging
bun run deploy:productionCI/CD flow: feature branch → PR (lint/typecheck/tests/build) → merge to main auto-deploys
staging → manual workflow_dispatch promotes to production (protected environment).
See .github/workflows/deploy.yml header for required secrets and rollback commands
(wrangler rollback, D1 time travel).
.github/workflows/ci.yml fails on lint errors, type errors, test failures, or invalid
Wrangler config. Deployments require CLOUDFLARE_API_TOKEN (least privilege: Workers Scripts,
D1, KV, R2, Queues edit on the relevant resources only) and CLOUDFLARE_ACCOUNT_ID.
GET /api/v1/openapi.json— spec generated from route definitions (never drifts)GET /docs— Scalar UI- Auth:
Authorization: Bearer <jwt>orAuthorization: Bearer nb_live_/nb_test_... - All responses use
{ success: true, data }/{ success: false, error: { code, message, details?, requestId? } } - Rate-limit headers:
X-RateLimit-Limit|Remaining|Reset; 429 includesRetry-After - Idempotency: send
Idempotency-Key: <uuid>on POST/PATCH; replays return the cached response for 24h; key reuse with a different payload returns 409
src/
├── index.ts # fetch/queue/scheduled entrypoint
├── app/ # bootstrap, router mounts, shared context types
├── config/ # typed env validation, constants
├── db/ # drizzle client + schema (users, orgs, sessions, keys, uploads…)
├── middleware/ # request-id, logging, security, error, rate-limit, auth, idempotency
├── modules/
│ ├── auth/ # routes, service, schemas, sessions/api-keys repositories
│ ├── users/
│ ├── organizations/
│ ├── uploads/ # routes + repository (R2 via storage.service)
│ ├── webhooks/ # verifier (HMAC+replay), service, routes
│ └── health/ # /health, /ready
├── services/ # cache, queue producer, email providers, storage, turnstile
├── jobs/ # queue consumer (dispatch by type) + scheduled cleanup
├── lib/ # logger, crypto, pagination, response envelopes, openapi helpers
├── repositories/ # cross-module data access (users)
├── types/ # Env bindings type
└── utils/ # errors hierarchy, dates, strings
migrations/ # generated SQL (drizzle-kit)
scripts/ # seed.sql
tests/{unit,integration,e2e}
.github/workflows/{ci,deploy}.yml
| Command | What it does |
|---|---|
bun run dev |
Local worker with live reload |
bun run build |
Dry-run worker bundle |
bun run typecheck |
tsc --noEmit |
bun run lint / format |
Biome |
bun run test[:watch] |
Test suite |
bun run db:* |
Migrations/studio/seed (see section 8) |
bun run deploy:* |
Staging/production deploys |
[ ] ENVIRONMENT=production, LOG_LEVEL=info set in wrangler.toml [env.production]
[ ] ALLOWED_ORIGINS lists exact production origins (no wildcards)
[ ] Secrets added via wrangler secret put: JWT_SECRET (+ TURNSTILE_SECRET_KEY, RESEND_API_KEY if used)
[ ] Production D1 database created; migrations reviewed and applied
[ ] KV namespace, R2 bucket, Queue + DLQ created and bound
[ ] Custom domain configured (routes in wrangler.toml)
[ ] Rate limits reviewed for expected traffic (config/constants.ts)
[ ] Logging verified via `wrangler tail`; log shipper connected if used
[ ] Error tracking hooked into error.middleware.ts if desired
[ ] GET /health and GET /ready return 200 after deploy
[ ] CI/CD deployment tested end-to-end (secrets present, health check passes)
[ ] Rollback strategy documented and rehearsed (wrangler rollback + D1 time-travel)
[ ] Email provider configured and tested (default console provider does NOT deliver)
[ ] Turnstile enabled on public forms if bot protection is required