Cloudflare Workers Deployment¶
API deployment guide for MeisterBill on Cloudflare Workers.
Overview¶
The MeisterBill API is deployed as a Cloudflare Worker:
- Runtime: V8 isolates (JavaScript/TypeScript)
- Framework: Hono with OpenAPI
- Edge Locations: 300+ cities worldwide
- Cold Starts: Zero (instant response)
Architecture¶
βββββββββββββββββββ ββββββββββββββββββββ βββββββββββββββ
β User Request ββββββΆβ Cloudflare Edge ββββββΆβ Worker β
βββββββββββββββββββ ββββββββββββββββββββ βββββββββββββββ
β
ββββββββββββββββββββ β
β Supabase (DB) βββββββββββββββββββ
ββββββββββββββββββββ
Key Differences from Traditional Servers: - No persistent server process - Code runs on request, then terminates - Environment variables injected per-request (not at startup) - 5-minute max execution time
Prerequisites¶
# Install Wrangler CLI
npm install -g wrangler
# Login to Cloudflare
wrangler login
# Verify authentication
wrangler whoami
Environments¶
| Environment | Worker Name | URL |
|---|---|---|
| Production | meisterbill-api |
https://api.meister-bill.com |
| Staging | meisterbill-api-staging |
https://api-staging.meister-bill.com |
Configuration¶
Config File: apps/api/wrangler.jsonc
{
"name": "meisterbill-api-dev",
"main": "src/worker.ts",
"compatibility_date": "2025-02-20",
"compatibility_flags": ["nodejs_compat"],
"browser": { "binding": "BROWSER" },
"env": {
"staging": { "name": "meisterbill-api-staging", ... },
"production": { "name": "meisterbill-api", ... }
}
}
Deployment¶
Set Secrets (Required First Time)¶
Secrets must be set for each environment separately:
cd apps/api
# Production secrets
wrangler secret put SUPABASE_KEY --env production
wrangler secret put STRIPE_SECRET_KEY --env production
wrangler secret put STRIPE_WEBHOOK_SECRET --env production
wrangler secret put STRIPE_CONNECT_CLIENT_ID --env production
wrangler secret put OPENAI_API_KEY --env production
wrangler secret put NEWSLETTER_API_KEY --env production
wrangler secret put MAILSERVER_PASS --env production
wrangler secret put SENTRY_DSN --env production
# Staging secrets (use test keys)
wrangler secret put SUPABASE_KEY --env staging
wrangler secret put STRIPE_SECRET_KEY --env staging
# ... etc
Or use the helper script:
./set-secrets-production.sh
Deploy¶
cd apps/api
# Deploy to staging
pnpm run deploy:staging
# Deploy to production
pnpm run deploy
# or
pnpm run deploy:production
Local Development¶
Use Node.js (NOT Workers) for local development:
# Start API with Node.js + tsx
pnpm --filter @meisterbill/api dev
# Or from apps/api directory
pnpm dev
The Cloudflare Worker runtime is only used for deployment. Local development uses the traditional Node.js server (src/index.ts).
PDF Generation¶
PDF generation uses Cloudflare Browser Rendering (not Gotenberg):
import puppeteer from '@cloudflare/puppeteer'
const browser = await puppeteer.launch(env.BROWSER)
const page = await browser.newPage()
await page.setContent(html)
const pdf = await page.pdf({ format: 'A4' })
Cost: $0.50 per 1000 renders
Monitoring¶
View logs:
# Real-time logs
wrangler tail --env production
# Staging logs
wrangler tail --env staging
Observability Dashboard: - Cloudflare Dashboard β Workers & Pages β meisterbill-api
Troubleshooting¶
Build Errors¶
Error: Cannot find name 'Fetcher'
# Install workers types
pnpm add -D @cloudflare/workers-types
Error: STRIPE_SECRET_KEY environment variable is required
- Secrets are read at runtime, not build time
- Ensure secrets are set: wrangler secret put STRIPE_SECRET_KEY --env production
- Code must use getEnv() helper, not process.env
Deployment Errors¶
Error: code: 10021
- Check worker logs: wrangler tail --env production
- Usually missing secrets or runtime errors
Error: code: 100117 (hostname already has externally managed DNS records)
- Delete any existing A/CNAME records for the subdomain in Cloudflare DNS first
- Then re-deploy: wrangler deploy --env production
Health Check Failures¶
/health returns 503 with environment error
The health check validates all required environment variables. If any are missing:
- Check which variables are missing in the response (temporarily added
missingarray to response) - Add missing vars to
wrangler.jsoncunder the appropriate environment'svarssection - Re-deploy
Common missing variables:
- MAILSERVER_HOST, MAILSERVER_PORT, MAILSERVER_USER - Add to wrangler.jsonc vars
- ENABLE_SIGNUPS - Add to wrangler.jsonc vars (set to "true" or "false")
Environment Variable Access¶
Critical: In Cloudflare Workers, process.env is NOT available at module load time.
Always use the getEnv() helper:
import { getEnv } from '../utils/env'
// β Wrong - won't work in Workers
const apiKey = process.env.API_KEY
// β
Correct - works in both Node.js and Workers
const apiKey = getEnv('API_KEY')
For code that runs during module initialization (outside request handlers), use lazy initialization:
import { getEnv } from '../utils/env'
class MyService {
private apiKey: string
constructor() {
// Read env at instantiation time, not module load time
const apiKey = getEnv('API_KEY')
if (!apiKey) throw new Error('API_KEY required')
this.apiKey = apiKey
}
}
// Lazy initialization - only create when needed
let service: MyService | null = null
const getService = () => {
if (!service) service = new MyService()
return service
}
Migration from Fly.io¶
| Aspect | Fly.io (Old) | Cloudflare Workers (New) |
|---|---|---|
| Deploy Time | ~10 minutes | ~12 seconds |
| Cold Starts | Yes (slow) | Zero |
| Global Locations | Few regions | 300+ edge cities |
| Runtime | Bun/Node.js | V8 isolates |
| PDF Service | Gotenberg | Browser Rendering API |
Cost Comparison¶
| Service | Fly.io | Cloudflare Workers |
|---|---|---|
| API Hosting | ~$5-20/mo | Free tier: 100k/day, then $0.50/million |
| PDF Generation | ~$5/mo (Gotenberg) | $0.50 per 1000 renders |
| Bandwidth | Included | Included |
Additional Resources¶
Custom Domains¶
The API is configured with custom domains:
| Environment | Domain | DNS |
|---|---|---|
| Production | api.meister-bill.com |
Managed by Cloudflare |
| Staging | api-staging.meister-bill.com |
Managed by Cloudflare |
Config in wrangler.jsonc:
"routes": [
{
"pattern": "api.meister-bill.com",
"custom_domain": true
}
]
Note: Delete any existing DNS records for the subdomain before deploying, or the deployment will fail with "hostname already has externally managed DNS records".
D1 Content Database (Blog & Glossary)¶
The Nuxt Content module uses Cloudflare D1 for storing blog posts and glossary entries.
Configuration¶
wrangler.toml (apps/web/wrangler.toml):
name = "meisterbill-web"
compatibility_date = "2025-02-20"
compatibility_flags = ["nodejs_compat"]
pages_build_output_dir = "dist"
# D1 Database binding for Nuxt Content (blog, glossary)
[[d1_databases]]
binding = "DB"
database_name = "meisterbill-content"
database_id = "5041bf13-e264-4b05-bc57-edc1ad87f4ce"
Important: Unlike Workers, Cloudflare Pages uses top-level bindings (not [env.production.*]). The binding = "DB" must match the binding name in nuxt.config.ts:
content: {
database: {
type: isProduction ? 'd1' : 'sqlite',
binding: 'DB', // Must match wrangler.toml
},
}
Deployment Behavior¶
Current Implementation: During deployment, the SQL dumps are applied to D1 using a full replace strategy:
# Decompress and apply gzip-compressed dumps
gunzip -c dist/dump.blog.sql > /tmp/blog.sql
wrangler d1 execute meisterbill-content --file=/tmp/blog.sql --remote
SQL Operations:
DROP TABLE IF EXISTS _content_blog; -- Table dropped
CREATE TABLE IF NOT EXISTS _content_blog ... -- Table recreated
INSERT INTO _content_blog VALUES ... -- All data inserted
Brief Downtime Window¶
Impact: There is a ~3ms window during deployment where blog/glossary tables are empty.
Typical Import Times: | Collection | Size | Import Time | |------------|------|-------------| | Blog | ~70KB compressed | ~3ms | | Glossary | ~23KB compressed | ~1ms |
Risk Assessment: - Low traffic sites: Negligible impact - users unlikely to hit the empty window - High traffic sites: Possible brief 404s or empty content during deployment - SEO: Minimal risk due to extremely short window
Troubleshooting¶
Error: [db0] [d1] binding 'DB' not found
This error occurs when the D1 database binding is not properly configured. Check:
-
wrangler.toml has the binding at top-level (not under
[env.production]):toml [[d1_databases]] binding = "DB" database_name = "meisterbill-content" database_id = "5041bf13-e264-4b05-bc57-edc1ad87f4ce" -
nuxt.config.ts uses the same binding name:
typescript binding: 'DB' -
Database ID is correct - verify in Cloudflare Dashboard β D1
Note: Cloudflare Pages uses branches for environments, not --env flag like Workers.
Future Improvement¶
See Issue #276 for planned zero-downtime migration strategy using upserts instead of DROP/CREATE.
*Last updated: 2026-02-22
Deployment Checklist¶
- [ ] All secrets set via
wrangler secret put - [ ] All non-secret vars added to
wrangler.jsoncvarssection - [ ] Health check uses
getEnv()notprocess.env - [ ] Services use lazy initialization (not at module load time)
- [ ] No conflicting DNS records in Cloudflare
- [ ] Test
/healthendpoint returns 200 - [ ] Test
/openapiendpoint returns 200 - [ ] Test
/docsendpoint returns 200