How to get DailyTM onto the internet: the production app, the public demo, and keeping v1 alive beside them.
This document assumes SETUP.md has been read — it says what each variable does. This one says where to put them.
Anywhere that runs Node 20+
DailyTM is an ordinary Next.js application with a Postgres database. It has no Vercel-only dependency: background tasks use Next's own after() rather than @vercel/functions, storage speaks the S3 protocol to whatever endpoint you give it, and scheduled jobs are an HTTP route any scheduler can call.
pnpm install --frozen-lockfile
pnpm db:generate
pnpm exec prisma migrate deploy
pnpm build
pnpm start
The only platform-specific file is vercel.json, which declares one cron schedule. On any other host, delete it and call the same routes from your own scheduler — see SETUP.md.
Three deployments, and why they must stay apart
| Deployment | Branch | Database | Purpose |
|---|---|---|---|
| v1 (legacy) | legacy/v1 at tag v1.0.0 |
v1's own | Existing buyers. Frozen except for v1.0.x patches |
| v2 production | main |
its own | The real thing |
| Demo | main |
throwaway | Public, resets hourly, credentials on the landing page |
Each gets its own deployment project and its own database. That is not tidiness:
- The demo runs a cron job that deletes an organization on a schedule. Sharing a database with production means that job eventually deletes a customer's data.
- v1 and v2 use different identity providers, different storage, and different billing. A variable that leaks between them configures a feature that does not exist.
Confirm before shipping: no v1 deployment holds a BETTER_AUTH_*, POLAR_*, or S3_* variable, and no v2 deployment holds a Kinde, UploadThing, or Lemon Squeezy variable.
v2 production
1. Create the project and point it at main
On Vercel: Settings → Environments → Production → Branch Tracking → main. Elsewhere, whatever your platform's equivalent is. Verify afterwards that the deployed commit SHA is the one you expect — a project still tracking v2-build will look fine and ship the wrong thing.
2. Environment variables
Required:
DATABASE_URL=…
BETTER_AUTH_SECRET=… # openssl rand -hex 32, different from every other environment
BETTER_AUTH_URL=https://app.example.com
Then whichever optional blocks you want, from SETUP.md. A realistic production set also has RESEND_API_KEY, EMAIL_FROM, CRON_SECRET, and the five S3_* variables.
NEXT_PUBLIC_DEMO_MODE must be unset or "false". It is the one flag that lets a scheduled job delete data.
3. Migrate before the first deploy
DATABASE_URL="<production url>" pnpm exec prisma migrate deploy
Never db push. Several indexes and constraints exist only inside migration files — see SETUP.md.
4. Attach the domain, then fix the origin
Attach the production domain, then set BETTER_AUTH_URL to it and redeploy. A BETTER_AUTH_URL still pointing at the preview URL breaks OAuth callbacks and secure-cookie issuance in ways that look like random sign-outs.
Add any other legitimate origin to BETTER_AUTH_TRUSTED_ORIGINS — a separate marketing domain, or preview deployments if people sign in on them.
5. Scheduled jobs
vercel.json declares due-date-reminder and orphan-sweep, and Vercel sends CRON_SECRET as a bearer token automatically. Nothing else to do.
Both are daily, deliberately. Vercel's free plan runs cron jobs at most once per day and fails the deployment on anything more frequent:
Hobby accounts are limited to daily cron jobs. This cron expression (
0 * * * *) would run more than once per day.
A default configuration that cannot deploy on the free tier is a defect, so every schedule that ships here is daily. If you want a job to run more often, that is a paid-plan change to vercel.json — not something to discover at deploy time.
On any non-Vercel host, delete vercel.json and call both routes from your own scheduler; see SETUP.md.
Do not add demo-reset to a production scheduler. It is a no-op there because DEMO_MODE is off, but the schedule is a loaded gun aimed at a safety catch.
6. Verify, in this order
/statusrenders and the server time is a realDate.- Sign up with a real address; the verification email arrives.
- Create an organization, invite a second address, accept from a different browser.
- Create a project, drag a card between columns, reload — the move stuck.
- Upload an attachment from the browser. This is the only check that exercises the bucket's CORS policy, and it is the one that fails in a way no server-side test can catch.
curl -H "Authorization: Bearer $CRON_SECRET" https://app.example.com/api/cron/due-date-reminderreturns JSON, and the same call without the header returns 401.https://app.example.com/robots.txtand/sitemap.xmlrender, and the sitemap's URLs use the production origin.- Paste the URL into Slack. The social card shows the DailyTM mark and the tagline.
The public demo
A buyer clicks before they pay. The demo exists so there is something to click.
It is read-heavy by design: no email delivery, no billing, no storage, and nothing a visitor does survives the next reset.
1. Its own everything
A separate deployment project, tracking the same main, with a separate throwaway database. It must share no credential with v1 or with v2 production — not the database, not BETTER_AUTH_SECRET, not CRON_SECRET.
2. Variables
DATABASE_URL=<the throwaway database>
BETTER_AUTH_SECRET=<its own, freshly generated>
BETTER_AUTH_URL=https://demo.example.com
CRON_SECRET=<its own, freshly generated>
NEXT_PUBLIC_DEMO_MODE=true
And deliberately absent: RESEND_API_KEY, every S3_*, every POLAR_*. Each absence removes a feature the demo should not have — a visitor cannot send email from your domain, cannot upload files that outlive a reset, and cannot reach a checkout.
3. Seed it once
DATABASE_URL="<demo url>" pnpm exec prisma migrate deploy
NODE_ENV=production DATABASE_URL="<demo url>" pnpm db:seed -- --force
--force is required under NODE_ENV=production and is what the flag is for: the seed is destructive and writes accounts whose password is printed on the terminal, so it refuses to run in production unless somebody says the word.
4. The reset
/api/cron/demo-reset wipes the northwind organization and every @dailytm.demo user, then writes them again from the same module pnpm db:seed uses.
Nothing schedules it for you, and vercel.json deliberately does not declare it. Two reasons: a demo wants resetting more often than daily, which the free plan refuses outright; and a demo-reset schedule shipped to every buyer is a loaded gun regardless of plan. Drive it from outside instead, with a free external cron service or by hand:
curl -H "Authorization: Bearer $CRON_SECRET" https://<your-demo>/api/cron/demo-reset
That is byte-for-byte the request Vercel's scheduler would make, and it meets the same two guards below. Hourly is generous for a demo; daily is usually enough.
It is guarded twice, and both guards matter:
CRON_SECRET— without it the route 404s, like every job.DEMO_MODE— without it the job returns zero and touches nothing, even with a valid secret. The secret proves the caller is entitled; it says nothing about whether this deployment is a demo. A promoted preview, a copiedvercel.json, or a.envpasted between projects would otherwise wipe a customer's organization on a perfectly valid request.
5. Verify
- The landing page shows the demo credentials block. It must not appear on production.
- Sign in as
ada@dailytm.demo, delete a project, then trigger the reset by hand:
The project is back and the response readscurl -H "Authorization: Bearer $CRON_SECRET" https://demo.example.com/api/cron/demo-reset{"job":"demo-reset","reset":1,…}. - Sign in as
sam@dailytm.demo— aviewer. No create, edit, or delete control renders anywhere. - Confirm the same
demo-resetcall against production returns{"reset":0,…}and changes nothing. This is the guard that matters most; test it deliberately rather than assuming it.
Keeping v1 alive
- Point the existing v1 deployment project at
legacy/v1— Settings → Environments → Production → Branch Tracking. - Verify the deployed commit SHA matches tag
v1.0.0, and that it is unaffected by any commit tomain. - Confirm the variable separation above.
- Patches ship as
v1.0.xfromlegacy/v1. Never a stack change.
A clean clone of tag v1.0.0 must reach a running app using only the legacy setup guide on that branch. That check is a blocking item on the release checklist and it has to be done from a machine that has never built v1 before — a working copy proves nothing about a buyer's path.
Rolling back
Deployments are immutable; roll back by promoting the previous one. The thing that does not roll back is the database: prisma migrate deploy is forward-only, and a rollback to a build that predates a migration will meet a schema it does not expect.
So: before a deploy that carries a migration, take a snapshot. Neon and RDS both do point-in-time restore; take one anyway, because a restore you did not need is cheaper than the alternative.
If a migration must be undone, write a new migration that undoes it. Do not edit or delete an applied one — _prisma_migrations records what ran, and rewriting history there is how a schema and its record stop agreeing.