agentjob.ioHandbook

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

AppProductionNotes
apps/saasyesthe product; needs the database, mail, SMS, payments and the API
apps/marketingyespublic site, pricing, legal pages
apps/docsoptionalthe handbook, if you want it public
apps/mail-previewnoa 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:

vinextOpenNext
What it doesreimplements the Next.js API on Viteconsumes the next build output
This app's score88% compatible (npx vinext check: 20 supported, 2 partial, 2 issues)n/a — no source change
Blocking issuea 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:socketskeeps that bundle exactly as verified locally
Alsonext/font/google becomes CDN-loaded, so the Arabic font is no longer self-hostedfonts 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_compat is required: oRPC + Hono and Prisma over pg run 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:worker and pnpm deploy:worker, which wrap the app scripts in dotenv -c because the adapter shells out to next build and that build needs DATABASE_URL.

Verified locally

pnpm build:worker                          # next build + adapter
pnpm --filter saas exec wrangler deploy --dry-run   # validate the bundle, publish nothing
  • next build compiles.
  • The adapter produces .open-next/ (77 MB) with worker.js, assets/, server-functions/, middleware/ and the Cloudflare templates.
  • wrangler deploy --dry-run reads 82 asset files, reports a 37 MB upload (8.5 MB gzipped) and the env.ASSETS binding, 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:41

Setting 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:worker

After 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 only
  • DATABASE_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:

VariableWhy it matters
NEXT_PUBLIC_APP_NAMEproduct name in UI, metadata and mail
NEXT_PUBLIC_SAAS_URL, NEXT_PUBLIC_MARKETING_URL, NEXT_PUBLIC_DOCS_URLabsolute URLs, sitemaps, auth callbacks
DATABASE_URL, DIRECT_URLthe database
BETTER_AUTH_SECRETsession signing; rotating it signs everyone out
MAIL_PROVIDER + credentials, MAIL_FROMverification and notification email
SMS_PROVIDER + credentialsmobile OTP sign-in
PAYMENTS_PROVIDER=moyasar (or stripe) + MOYASAR_API_KEY, MOYASAR_WEBHOOK_SECRETsubscriptions
PRICE_ID_*maps provider prices back to plan ids
S3_* + bucket namestenant-scoped uploads (R2, SeaweedFS or S3)
ANTHROPIC_API_KEY (optional), AI_MODELmodel answers; without a key the product runs on the local engine
USAGE_REPORT_KEYlets an external worker report usage; leave empty to disable
PRODUCT_TIMEZONEdecides 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)

  1. Create the account and switch to live keys.
  2. Create a price/subscription for each plan × interval, then set PRICE_ID_<PLAN>_<INTERVAL>.
  3. Register the webhook URL https://<your-domain>/api/webhooks/payments and set MOYASAR_WEBHOOK_SECRET. Verification is constant-time over the custom header, the bearer form and the basic form; it is permissive outside production only.
  4. Dry run with a real card and the smallest plan, then confirm: a Purchase row 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_FROM on 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 command

Schema 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.mjs

It 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 by org: 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

  1. Redeploy the previous build (both apps).
  2. 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.
  3. 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.failed entries in the audit trail.
  • Confirm at least one subscription webhook arrived and mapped to a plan.
  • Confirm the first real user reaches /<workspace>/today and closes a day.
  • Keep the seed script and production strictly apart: pnpm db:seed must never run there.

On this page