Developer Guide

Deploy to Cloudflare

Deploy OpenStory to Cloudflare Workers with D1 and R2

OpenStory deploys to Cloudflare Workers, using D1 (SQLite) for the database and R2 for media storage.

One-Click Deploy

Deploy to Cloudflare

The deploy button clones the repo into your GitHub/GitLab account, provisions the resources declared in wrangler.jsonc, prompts for the secrets listed in .dev.vars.example, and sets up CI for your copy.

The created repo is an independent clone, not a fork — there's no upstream link for GitHub's "Sync fork" button. To pull future OpenStory updates into a button-deployed copy, add the upstream remote manually (git remote add upstream https://github.com/openstory-so/openstory && git pull upstream main). If you'd rather start from a real fork, fork on GitHub first, then connect the fork to Workers Builds in the Cloudflare dashboard (or deploy from a local clone with bun setup --prod).

AI keys (FAL_KEY, OPENROUTER_KEY) are deliberately not part of the deploy prompts — every field in that dialog is mandatory, and a placeholder value would be worse than none. Add them after deploy, either per team in the app (Settings → API Keys) or server-wide with wrangler secret put.

Guided Setup

From your own clone, bun setup --prod walks through everything interactively: production env vars (.env.production), R2 domains + CORS, optional services, pushing secrets to Cloudflare and GitHub, and the first deploy. bun setup --deploy re-runs just the secrets-push + deploy phase, and bun setup --pr-preview pushes preview secrets to the GitHub staging environment used by PR preview deploys.

Prerequisites

  • A Cloudflare account
  • Wrangler CLI (installed as a dev dependency — use bunx wrangler)
  • CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN available in your environment

wrangler.jsonc

Bindings live in wrangler.jsonc at the repo root:

  • DB — D1 database (openstory-prd)
  • R2_PUBLIC_ASSETS_BUCKET — public assets (served via custom domain)
  • R2_STORAGE_BUCKET — private storage for generated media

The Worker entry point is src/server.ts with nodejs_compat enabled.

Build & Deploy

Upstream production (openstory-so/openstory → the openstory worker) deploys through Workers Builds — the same mechanism Deploy-to-Cloudflare button clones use, so upstream dogfoods the exact pipeline users get. Workers Builds is wrangler-authenticated, so the prod path needs no Cloudflare secrets in GitHub. The dashboard configuration (the only deploy state not versioned in the repo):

  • Repository / branch: openstory-so/openstory, main
  • Build command: bun run build
  • Build env vars: CLOUDFLARE_ENV=production (bakes the [env.production] block into dist/server/wrangler.json), VITE_R2_PUBLIC_ASSETS_DOMAIN, VITE_PUBLIC_POSTHOG_PROJECT_TOKEN, VITE_PUBLIC_POSTHOG_HOST
  • Deploy command: bun run deploy:production — flatten migrations → wrangler d1 migrations apply DB --env=production --remote → wrangler deploy. The plain wrangler deploy picks up the flattened dist/server/wrangler.json via .wrangler/deploy/config.json; the --env flag matters only for the migrate step, which reads the source wrangler.jsonc.

For a manual deploy from a local checkout:

# Generate Worker types from wrangler.jsonc
bun cf:typegen

# Deploy to production (typegen, CLOUDFLARE_ENV=production build, migrate, wrangler deploy)
bun cf:deploy:prd

Database Migrations

Remote databases are migrated with wrangler d1 migrations apply (tracked in wrangler's d1_migrations table). drizzle-kit only generates migrations (bun db:generate); it never touches a remote database.

Because drizzle-kit emits nested <timestamp>_<name>/migration.sql files that wrangler can't read, scripts/flatten-migrations.ts renders them to flat drizzle/migrations-wrangler/*.sql (gitignored) first. The relevant scripts run it automatically:

bun run deploy             # flatten → wrangler d1 migrations apply DB --remote → wrangler deploy
                           # (what Deploy to Cloudflare button clones run)
bun run deploy:production  # same, but --env=production on the migrate step
                           # (what upstream's Workers Builds runs)
bun db:migrate:prd         # flatten → wrangler d1 migrations apply DB --env=production --remote
  • Button deploys: the deploy package script is picked up as the deploy command and re-runs on every push, so new migrations apply idempotently. It references the binding name DB (not the database name) so it works whatever the user named their database.
  • Upstream production (Workers Builds): deploy:production is the configured deploy command — identical to deploy except the migrate step targets the [env.production] D1.
  • PR previews: CI applies migrations with wrangler d1 migrations apply DB --remote after patching the PR's database id into the config, then seeds system templates through the D1 HTTP API before deploying the Worker.
  • Local dev / e2e: unchanged — drizzle-orm's migrator applies the nested files directly against the Miniflare binding (bun db:migrate:local).

Migrations must stay backwards-compatible for the moment between migrate and deploy, and the D1 table-rebuild CASCADE trap (see AGENTS.md) applies to every remote apply path.

Seeding

The worker self-seeds system templates on first request (src/server.ts → src/platform/server/db/seed-system-templates.ts). PR previews run the same seed before deployment so a fresh database's remote writes do not delay the first page load. A hash of the template definitions is stored in app_metadata; when it matches, the check is a single SELECT per isolate, and when it doesn't (fresh database, or a deploy that changed templates) the idempotent sync runs once. bun db:seed:local / bun scripts/seed.ts --test reuse the same module for local setup.

Secrets

Secrets are pushed to the Worker via wrangler secret bulk. The full list is defined in .github/workflows/deploy-cloudflare.yml. Core secrets include:

VariableDescription
BETTER_AUTH_SECRETBetter Auth signing secret
VITE_APP_URLPublic URL of the deployment
FAL_KEYfal.ai API key for image/video generation
OPENROUTER_KEYOpenRouter API key for LLM script analysis
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRETGoogle OAuth credentials
EMAIL_FROMSender address for transactional email (domain onboarded in Cloudflare Email Service)

Prod database cutover (velro-prd → openstory-prd)

One-time runbook for #897: the production D1 predates the project rename (the account still calls it velro-prd) and its migration history lives in drizzle's __drizzle_migrations table, which wrangler doesn't read. D1 has no rename, so the move is a recreate — which also gives the new database a fully native d1_migrations history with no baselining:

# 1. Snapshot (also the import source). Quiet window: writes after this are lost.
bunx wrangler d1 export velro-prd --remote --output=backup.sql

# 2. Create the fresh DB and flip wrangler.jsonc to it FIRST:
#    set [env.production].d1_databases[0].database_id to the new DB's id
#    (keep database_name openstory-prd). Unlike `d1 export`/`execute`,
#    `d1 migrations apply` resolves ONLY from wrangler config — it can't
#    look a database up by name, and without the flip `--env=production`
#    would target the old velro id.
bunx wrangler d1 create openstory-prd
# ... edit wrangler.jsonc ...

# 3. Apply all migrations to the empty DB (rebuild-pattern migrations in our
#    history are CASCADE-safe with no rows). Same command prod CI runs:
bun db:migrate:prd

# 4. Data-only import. Three transforms on the raw export:
#    - drop drizzle's tracking rows (that table doesn't exist in the new DB —
#      wrangler's d1_migrations replaces it)
#    - reorder tables parent-first: D1 ingests large files in multiple
#      internal transactions, the dump's single leading
#      `PRAGMA defer_foreign_keys=TRUE` doesn't span them, and the export's
#      alphabetical order (`account` before `user`) fails at a chunk commit
#      with "FOREIGN KEY constraint failed" and rolls the import back.
#      Parent-first order needs no deferral, so chunking can't break it.
bunx wrangler d1 export velro-prd --remote --no-schema --output=data-raw.sql
grep -v '__drizzle_migrations' data-raw.sql > data.sql
bun scripts/reorder-d1-dump.ts data.sql data-ordered.sql
bunx wrangler d1 execute openstory-prd --remote --file=data-ordered.sql

# 5. Commit the wrangler.jsonc flip, merge, deploy. The deploy's
#    `wrangler d1 migrations apply` reports everything already applied.

# 6. Soak, then delete the old DB:
bunx wrangler d1 delete velro-prd

Merge order matters: run this runbook (through step 4) before merging the PR that contains the id flip. Merging first isn't destructive — wrangler sees an empty d1_migrations on the old DB, migration #1's CREATE TABLE fails against the existing tables, the file rolls back, and CI fails loudly with the previous deploy still serving — but it blocks deploys until the cutover is done.

Existing PR-preview databases also only have drizzle tracking — close and reopen the PR to get a fresh, wrangler-tracked preview database.

CI/CD

Production pushes to main deploy via Workers Builds (see Build & Deploy above). deploy-cloudflare.yml handles the PR previews, which stay on GitHub Actions because Workers Builds branch previews share production bindings — no per-PR D1 provisioning, no workflow-name namespacing, no teardown on close:

  • PR previews: each PR gets its own Worker (pr-<number>) and D1 database (openstory-pr-<number>), with secrets pushed and the preview URL posted as a PR comment. Previews also get namespaced workflows (*-pr-<number>) and a video-export container application (pr-<number>-videoexportcontainer). On its first deploy, an empty PR database is copied from the latest promoted preview base via a full D1 SQL export/import, including migration history. A database that already has tables is recorded at the current base revision and kept as-is, including a retry after a partial import. Later deploys that already have a fork record retain its data and apply only pending migrations. The first PR starts empty. Production D1 is never a source or destination.
  • Preview base and promotion (#1997): openstory-preview-registry is a tiny D1 control database holding the active base database ID and revision, plus each PR's fork revision. After a merged PR's Worker is stopped, CI promotes its D1 by pointing the registry at that database. The promoted D1 is retained (as are earlier promoted databases for manual recovery); no live database is overwritten. A conditional D1 update atomically compares the fork revision with the current base, even if two PRs merge concurrently. If another PR has advanced the base since a PR forked, promotion fails, comments on the PR, and preserves its D1 for manual reconciliation. To recover, fork a new PR from the current base, explicitly copy the desired rows from the preserved database, and merge that PR; do not force-promote the stale snapshot. There is no automatic row-level merge or promotion to production. A previously opened PR is not implicitly rebased onto a new preview base; close and reopen only if you intend to discard its existing preview data.
  • Cleanup / re-fork: closing an unmerged PR deletes its Worker, namespaced workflows, container application, and D1 database and its fork record. Reopening that PR creates a new D1 from the then-current preview base, discarding its previous preview data. A merged PR cannot be reopened; use a new PR to adopt later base changes. Closing a merged PR keeps its D1 as the new base (or for conflict recovery). Container apps are account-level and outlive the worker — skipping them blocks reopened-PR redeploys and accrues provisioned instances (see #1052). The staging R2 bucket is shared among previews, so copied media URLs remain valid; media is not cloned or cleaned up with the D1.

Platform Detection

OpenStory automatically detects the deployment platform:

import { getDeploymentPlatform } from '@/shared/utils/environment';

const platform = getDeploymentPlatform();
// Returns: 'cloudflare' | 'local' | 'unknown'