Deploying to production
What to deploy, which secrets must exist, how migrations run, how to verify a release, and how to roll back. No containers are involved.
Deploying to production
What you deploy
| App | Production | Notes |
|---|---|---|
apps/saas | yes | the product; needs the database, mail, SMS, payments and the API |
apps/marketing | yes | public site, pricing, legal pages |
apps/docs | optional | the handbook, if you want it public |
apps/mail-preview | no | a development tool for email templates |
The API is not a separate service: it is served by apps/saas at /api/** (Hono + oRPC).
Cloudflare Workers: the configured path
The Cloudflare setup is OpenNext (@opennextjs/cloudflare), not vinext, and the choice is
evidence-based rather than aesthetic:
| vinext | OpenNext | |
|---|---|---|
| What it does | reimplements the Next.js API on Vite | consumes the next build output |
| This app's score | 88% compatible (npx vinext check: 20 supported, 2 partial, 2 issues) | n/a — no source change |
| Blocking issue | a custom webpack config is ignored by Vite, and this one is load-bearing: PrismaPlugin bundles the Prisma client and the ignore list shims pg-native / cloudflare:sockets | keeps that bundle exactly as verified locally |
| Also | next/font/google becomes CDN-loaded, so the Arabic font is no longer self-hosted | fonts and build stay as they are |
Re-run npx vinext check in apps/saas before revisiting this. The migration is worth it once the
Prisma bundling has a Vite-native answer and the font is self-hosted with next/font/local; until
then vinext would deploy a bundle that differs from the one the tests exercised.
What is already in the repository
apps/saas/open-next.config.ts— minimal on purpose: this app is authenticated and dynamic, so there is nothing to cache at the edge yet (add the R2 incremental cache when a page earns it).apps/saas/wrangler.jsonc— worker name,nodejs_compat(nodejs_compatis required: oRPC + Hono and Prisma overpgrun inside the Worker), assets binding, observability, and the Hyperdrive note.apps/saas/package.json—build:worker,preview:worker,deploy:worker.- Root
package.json—pnpm build:workerandpnpm deploy:worker, which wrap the app scripts indotenv -cbecause the adapter shells out tonext buildand that build needsDATABASE_URL.
Verified locally
pnpm build:worker # next build + adapter
pnpm --filter saas exec wrangler deploy --dry-run # validate the bundle, publish nothingnext buildcompiles.- The adapter produces
.open-next/(77 MB) withworker.js,assets/,server-functions/,middleware/and the Cloudflare templates. wrangler deploy --dry-runreads 82 asset files, reports a 37 MB upload (8.5 MB gzipped) and theenv.ASSETSbinding, then exits without publishing.
What is left is credentials and secrets, not code.
The one adapter bug this stack hits, and the fix that is in the repo
pg-cloudflare (pulled in by pg for its Workers socket) maps its entry per export condition:
"exports": { ".": {
"workerd": { "import": "./esm/index.mjs", "require": "./dist/index.js" },
"default": "./dist/empty.js"
} }OpenNext's dependency tracer resolves with Node's conditions, so it copied only dist/empty.js; its
esbuild pass then resolved with the workerd condition and asked for dist/index.js, which was never
copied:
✘ [ERROR] Could not resolve "pg-cloudflare"
.open-next/server-functions/default/node_modules/.pnpm/[email protected]/node_modules/pg/lib/stream.js:41Setting cloudflare.useWorkerdCondition: false makes the build pass and is the wrong answer: the
Worker would receive the empty stub, so Postgres would fail at runtime instead of at build time.
The fix in this repo is a pnpm patch, patches/[email protected] (recorded in
pnpm-workspace.yaml under patchedDependencies, so a fresh pnpm install applies it), pointing the
default condition at dist/index.js as well. That makes the tracer and the bundler agree on the
real implementation, which is what a Worker needs; the module only requires events at load time, so
it stays inert in the Node build. Remove the patch when the adapter's tracer becomes
condition-aware — the failure to watch for is the same Could not resolve line.
Deploy procedure
npx wrangler login # once, per machine (or set CLOUDFLARE_API_TOKEN)
export CLOUDFLARE_ACCOUNT_ID=<id> # or add "account_id" to wrangler.jsonc
wrangler secret put DATABASE_URL # Postgres connection string, pooled provider preferred
wrangler secret put BETTER_AUTH_SECRET
# plus the mail / SMS / payments / storage / AI secrets the features you enable need
pnpm deploy:workerAfter the first deploy: register the payment webhook at
https://<worker-domain>/api/webhooks/payments, set NEXT_PUBLIC_* URLs to the deployed domain,
and run the verification script against it (docs/mcp.md and this file both use it).
Database
Use managed PostgreSQL (Neon, RDS, Cloud SQL, or a Saudi-hosted provider if data residency requires it).
# once, per environment
pnpm --filter @repo/database migrate # creates the migration locally
pnpm --filter @repo/database exec prisma migrate deploy # applies it in production
# never in production
pnpm --filter @repo/database push # prototyping only
pnpm db:seed # demo data onlyDATABASE_URL— pooled connection used by the app.DIRECT_URL— direct connection, when a pooler sits in front (required by migrations).- On Workers, bind the pool through Hyperdrive rather than opening a connection per request.
Secrets and configuration
Copy .env.local.example and fill it in; the production values that must exist:
| Variable | Why it matters |
|---|---|
NEXT_PUBLIC_APP_NAME | product name in UI, metadata and mail |
NEXT_PUBLIC_SAAS_URL, NEXT_PUBLIC_MARKETING_URL, NEXT_PUBLIC_DOCS_URL | absolute URLs, sitemaps, auth callbacks |
DATABASE_URL, DIRECT_URL | the database |
BETTER_AUTH_SECRET | session signing; rotating it signs everyone out |
MAIL_PROVIDER + credentials, MAIL_FROM | verification and notification email |
SMS_PROVIDER + credentials | mobile OTP sign-in |
PAYMENTS_PROVIDER=moyasar (or stripe) + MOYASAR_API_KEY, MOYASAR_WEBHOOK_SECRET | subscriptions |
PRICE_ID_* | maps provider prices back to plan ids |
S3_* + bucket names | tenant-scoped uploads (R2, SeaweedFS or S3) |
ANTHROPIC_API_KEY (optional), AI_MODEL | model answers; without a key the product runs on the local engine |
USAGE_REPORT_KEY | lets an external worker report usage; leave empty to disable |
PRODUCT_TIMEZONE | decides the day boundary (default Asia/Riyadh) |
Provider secrets are read lazily inside the provider, so a missing one breaks only the feature that needs it — never the whole app.
Payments (Moyasar)
- Create the account and switch to live keys.
- Create a price/subscription for each plan × interval, then set
PRICE_ID_<PLAN>_<INTERVAL>. - Register the webhook URL
https://<your-domain>/api/webhooks/paymentsand setMOYASAR_WEBHOOK_SECRET. Verification is constant-time over the custom header, the bearer form and the basic form; it is permissive outside production only. - Dry run with a real card and the smallest plan, then confirm: a
Purchaserow appears, the plan unlocks in the workspace, and the capability map (capabilitiesForPlan) reflects it.
Mail, SMS and storage
- Mail: verify the sending domain, publish SPF, DKIM and DMARC, set
MAIL_FROMon that domain. Send one real magic link and one invitation before launch. - SMS: register the sender id with Unifonic or Msegat, then test phone OTP end to end.
- Storage: create the buckets (
avatars,logos,media), allow CORS for the app origin so presigned uploads work, and confirm a tenant-prefixed key is written (tenants/<orgId>/…).
Release procedure
pnpm format:check && pnpm lint && pnpm type-check && pnpm test # the four gates
pnpm build # the gate dev mode hides
pnpm --filter @repo/database exec prisma migrate deploy # schema first
# then deploy the two apps with the host's commandSchema changes are backward compatible for one release: deploy the migration, then the app.
Verify the release (not just that it is up)
cp .agents/skills/template-to-product/scripts/verify-product.mjs apps/saas/verify-product.mjs
SAAS_URL=https://app.example.com MARKETING_URL=https://example.com \
DEMO_EMAIL=<a real account> DEMO_PASSWORD=<its password> \
node apps/saas/verify-product.mjs
rm apps/saas/verify-product.mjsIt signs in, walks the product routes, screenshots each one and fails on console errors. Do the same run against production after every release; it is the cheapest smoke test you own.
Also check:
GET /api/health→OK(liveness).GET /api/health?deep=1→ database reachability (readiness); wire this to your uptime monitor.- The audit trail shows the release's own actions on a workspace you control.
- Usage events appear for one real agent run.
Monitoring and operations
- Logs are structured and tenant-tagged (
tenantLogger); filter byorg:for one workspace. - Audit trail is the record for support and security questions; it is readable by owners on the team board and by you in the database.
- Usage answers "what does this tenant consume" without guessing.
- Add an uptime check on
/api/health?deep=1, an alert on 5xx rate, and a daily database backup with a restore you have actually tested once.
Rollback
- Redeploy the previous build (both apps).
- If a migration is involved, roll forward with a fix — do not roll a schema back under a running app. That is why schema changes ship one release ahead of the code that needs them.
- After a rollback, re-run the verification script and check the audit trail for the window.
First hour after launch
- Watch 5xx rate and the
automation.failedentries in the audit trail. - Confirm at least one subscription webhook arrived and mapped to a plan.
- Confirm the first real user reaches
/<workspace>/todayand closes a day. - Keep the seed script and production strictly apart:
pnpm db:seedmust never run there.
Connecting an assistant (MCP)
Create a workspace API key and give it to Claude Desktop, Cursor or any MCP client, so your own assistant can read your day, add tasks, run an agent pattern and close the day.
Launch checklist
The gates that stand between a working build and a product you can charge for. Each line has an owner and evidence — not an intention.