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:

  1. Check which variables are missing in the response (temporarily added missing array to response)
  2. Add missing vars to wrangler.jsonc under the appropriate environment's vars section
  3. 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:

  1. 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"

  2. nuxt.config.ts uses the same binding name: typescript binding: 'DB'

  3. 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.jsonc vars section
  • [ ] Health check uses getEnv() not process.env
  • [ ] Services use lazy initialization (not at module load time)
  • [ ] No conflicting DNS records in Cloudflare
  • [ ] Test /health endpoint returns 200
  • [ ] Test /openapi endpoint returns 200
  • [ ] Test /docs endpoint returns 200