Deployment (Vercel)

Next.js 16 on Vercel. Runs on Fluid Compute by default — no Edge-specific code needed.

First deploy

  1. Push repo to GitHub.
  2. Vercel dashboard → New Project → import repo.
  3. Framework preset: Next.js (auto-detected).
  4. Add environment variables (see ../getting-started/environment.md).
  5. Deploy.

Environment variables

Minimum for production. This list is the one in env.example marked REQUIRED IN PRODUCTION, plus the ones whose absence is a silent failure rather than a loud one — read the notes in that file for the full reasoning on each.

The second group is the reason this list used to be wrong. Every variable in it fails closed: unset, the feature does not degrade or throw, it refuses quietly and the deploy looks healthy. A build that boots, serves the dashboard and answers POST /v1/emails can still have all thirteen crons 401ing and every SES notification 403ing, with nothing on the sending side to say so.

# Core — nothing boots without these
DATABASE_URL=...
NEXTAUTH_SECRET=<openssl rand -hex 32>
NEXTAUTH_URL=https://<your-domain>

# Fail-closed — set these BEFORE the first cron tick / first send
CRON_SECRET=<openssl rand -hex 32>
SNS_TOPIC_ARN_ALLOWLIST=arn:aws:sns:...:sendoka-ses-events,arn:aws:sns:...:sendoka-sms-events,arn:aws:sns:...:sendoka-inbound-email
UNSUBSCRIBE_SECRET=<openssl rand -hex 32>
TRACKING_SECRET=<openssl rand -hex 32>
OVERAGE_LEDGER_FIRST_PERIOD=2026-08

# AWS — region is the deployment's own, not a default
AWS_ACCESS_KEY_ID=...
AWS_SECRET_ACCESS_KEY=...
AWS_REGION=ap-southeast-2

# Stripe — both tier prices, or a live subscription on the missing tier
# cannot be priced at all
STRIPE_SECRET_KEY=sk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
STRIPE_PRO_PRICE_ID=price_...
STRIPE_PAYG_PRICE_ID=price_...

# Upstash — the idempotency in-flight lock fails closed on production without it
UPSTASH_REDIS_REST_URL=...
UPSTASH_REDIS_REST_TOKEN=...

What each of the fail-closed five costs when unset:

Unset Symptom
CRON_SECRET cronAuthorized rejects every caller, Vercel Cron included, so all 13 jobs 401 on every tick. A 401 is an ordinary response and the platform still records the invocation, so nothing alerts. Scheduled sends never leave scheduled, webhook deliveries never retry, retention never prunes, overage is never metered.
SNS_TOPIC_ARN_ALLOWLIST Every SES / SMS / inbound-email notification is refused 403 by isAllowedTopic. Message statuses stay stuck at sent, bounces and complaints stop auto-suppressing, STOP replies are dropped. One sns.topic_allowlist_unset error line is the only signal.
UNSUBSCRIBE_SECRET Falls back to NEXTAUTH_SECRET rather than failing — the signer only throws when neither is set. Set it anyway: sharing the auth secret means rotating NEXTAUTH_SECRET invalidates every unsubscribe link already sitting in an inbox, and CAN-SPAM wants that mechanism working for at least 30 days after the send.
TRACKING_SECRET Same shape — falls back to UNSUBSCRIBE_SECRET, then NEXTAUTH_SECRET, and throws only if all three are unset. A dedicated value keeps a tracking-key rotation from logging everyone out.
OVERAGE_LEDGER_FIRST_PERIOD The report-overage cron never meters any period, because it cannot tell "not yet billed" from "already billed by the pre-ledger cron". Every org with overage lands in skipped_pre_ledger and the run returns 500 — daily, for the whole day 1-5 window, until it is set. Set it to the first period whose day-1 run happens after this deploy.

Also production-shaped, though not fatal on their own: SES_CONFIGURATION_SET (without it SES has nowhere to publish delivery / bounce / complaint events, so the allowlist above guards topics nothing ever writes to), SES_EVENT_TOPIC_NAME

  • AWS_ACCOUNT_ID (same, for dedicated IP pools, which pin their own configuration set), BLOB_READ_WRITE_TOKEN (org exports fall back to in-row JSON and can exceed Neon's row-size cap on a large org), and STRIPE_EMAIL_OVERAGE_METER_EVENT / STRIPE_SMS_OVERAGE_METER_EVENT / STRIPE_EMAIL_OVERAGE_PRICE_ID / STRIPE_SMS_OVERAGE_PRICE_ID if this deployment bills overage rather than flat rate. Full table with the consequences: environment.md.

Set per environment — Development / Preview / Production. The fail-closed group is permissive outside production by design (NODE_ENV !== "production"), so a Preview deploy that omits them will look fine and a Production one will not.

If AWS_REGION is anything other than us-east-1, domains added before the create-path fix carry the schema default "us-east-1" on domains.region instead of the region their SES identity is actually in. That is a one-time repair, not a redeploy: operations/domains-region-repair.md.

Before the first signup: the sandbox parent identity

Not an environment variable, not a migration, and not created by any code in this repo — but a production deploy is not finished without it.

Every org created by either signup path gets a domains row for {org-slug}.sandbox.sendoka.com, already verified and flagged platform_managed, with no SES call made. Those rows send because SES authorises a subdomain against a verified parent identity. Nothing here creates that parent: createDomainIdentity has two callers and both are customer add-a-domain routes. It is provisioned by hand, once, and after that the product quietly depends on it — the quickstart's first example and the platform walkthrough's "skip DNS — offer the sandbox" both offer live sending from a sandbox address, limited to the org's own members' verified sign-in addresses and 100 recipients per org per UTC day (emails.md).

Confirm it in the region the sandbox rows name, which is AWS_REGION:

aws sesv2 get-email-identity --email-identity sendoka.com --region ap-southeast-2

Correct: "VerifiedForSendingStatus": true, with "DkimAttributes": { "Status": "SUCCESS" }. sandbox.sendoka.com covers the same rows if that is the name verified instead. A NotFoundException from this command, or the same identity verified in some other region, means the same thing: every trial customer's first live send is rejected by SES, and nothing in the product says why. The send client takes its region from the domains row, so an identity in the wrong region is exactly as useless as none.

Quicker sanity check with no AWS access: system mail — login codes, password resets, invites — sends from no-reply@sendoka.com with no region argument, so it goes through AWS_REGION too. If those are arriving, an identity covering the parent is verified there. Full detail, including what to do when it is absent and why you must not add it through POST /v1/domains: integrations/aws-ses.md § The parent identity every sandbox domain rides.

/api/cron/probe-health re-checks this every five minutes once there is at least one platform-managed row. It is deliberately an operator signal — checks.sandbox_identity and sandbox_identity_unverified on the response, plus a cron.probe_health.sandbox_identity warn line — and never moves the status the public /status page renders.

CLI sync

vercel link
vercel env pull .env.local

Webhooks on production

After first deploy, register webhook endpoints in each provider:

Provider URL
Stripe https://<host>/api/webhooks/stripe
AWS SNS (SES events) https://<host>/api/webhooks/ses

Stripe: select all five events the handler switches on — checkout.session.completed, customer.subscription.created, customer.subscription.updated, customer.subscription.deleted, invoice.payment_failed. Copy signing secret → STRIPE_WEBHOOK_SECRET.

created and updated are not optional: a tier switched in the billing portal arrives only as customer.subscription.updated, so omitting them leaves the org on whatever tier it last checked out with while Stripe bills the new one.

SNS: HTTPS subscription. The app auto-confirms the subscription (fetches SubscribeURL on SubscriptionConfirmation message).

Database

Use Vercel Marketplace → Neon integration to provision and auto-populate DATABASE_URL. Alternatively: Neon console → create project → copy pooled connection string.

Migration order relative to the deploy

next build does not apply migrations, so a release and its schema land at different moments. Which one may go first is a property of the migration, and getting it backwards takes the app down.

There are three shapes, not two:

  • Additive, invisible to the new code — a new table nothing reads yet, a new index, a column no handler references. Safe in either order.
  • Additive, read or written by the new code — a new column the shipping build inserts into or selects. Migrate first, then deploy. Deployed first, the new build's own statements name a column that does not exist yet.
  • Restrictive — anything that takes a guarantee away from the code that is already running: DROP DEFAULT, SET NOT NULL, a narrowed CHECK, a dropped column. Deploy first, then migrate. Applied first, the build still in production starts violating the constraint.

The second and third shapes pull in opposite directions, and a release can carry both — so the order is not "migrations before the deploy" or "after" but a split around it:

apply the additive migrations  ->  deploy  ->  apply the restrictive ones

A worked example from this repo, all three in one release. 0054 (domains.platform_managed, NOT NULL DEFAULT false) is additive and the new code writes it, so it must land before the deploy. 0053 (domains.region DROP DEFAULT) and 0052 (the same on ip_pools.region) are restrictive and must land after it. Both touch the sandbox-domain INSERT that every signup runs, and that INSERT is raw SQL — the column list is invisible to Drizzle's types, so nothing catches the mismatch at build time. Get either half backwards and the failure is identical and total: every new registration and every OAuth auto-signup 500s inside the signup sql.transaction([...]) until the two halves agree.

Because a restrictive migration is only safe once the matching build is live, run that half after the production deployment has finished promoting — not while it is building.

Function regions

No explicit region pinning. Defaults per Vercel project config. For lowest latency, match the Vercel function region to the Neon project region.

Cold starts

Fluid Compute reuses instances across requests — warm instances retain the Neon sql client singleton and the AWS SDK clients. No tuning needed.

Build

npm run build

Vercel runs this automatically. Cron schedules live in vercel.ts — add rewrites / headers there if needed.

Post-deploy smoke test

curl https://<host>/api/v1/emails -H "Authorization: Bearer sok_test_..."
# Expect: 401 if unauth'd, 200 with empty data if key valid but no sends yet