Deploying the hosted version
The checklist for putting Plotra on Vercel, Cloudflare, Neon and R2, all on free tiers.
This is the order to do things in the first time the hosted version goes up. Each step ends with a check; don't start the next one until it passes. To run Plotra on your own server instead, see Self-hosting.
Everything here fits in a free tier. What each one allows, and what it would cost past that, is in section 8.3 of plan.md.
Addresses
The page is written for one domain, plotra.itzsudhan.com. To use another, change domain at the top of this file and the DOMAIN line below.
| What | Address | Runs on |
|---|---|---|
Marketing site (apps/web) | https://plotra.itzsudhan.com | Vercel |
App (apps/app) | https://app.plotra.itzsudhan.com | Vercel |
Docs (apps/docs) | https://docs.plotra.itzsudhan.com | Vercel |
Collab worker (apps/collab) | https://collab.plotra.itzsudhan.com, or its workers.dev address | Cloudflare Workers |
The commands on this page use these shell variables. Set them once in the terminal you work in:
DOMAIN=plotra.itzsudhan.com
APP_URL=https://app.$DOMAINGenerate two secrets now and keep them in a password manager. You will paste each in two places.
openssl rand -base64 32 # BETTER_AUTH_SECRET: signs sessions
openssl rand -base64 32 # COLLAB_SECRET: shared by the app and the collab worker1. Database: Neon
-
Create a Neon project. Choose Postgres 18 and the region closest to where Vercel runs the app's functions (
iad1, Washington D.C., unless you change it). -
From Connect, copy two connection strings:
- the pooled one (its host contains
-pooler): the app'sDATABASE_URL; - the direct one (pooling switched off): for migrations and for backups.
- the pooled one (its host contains
-
Run the migrations from the repository root, against the direct string:
bun install DATABASE_URL="<direct connection string>" bun run db:migrateA
DATABASE_URLin the environment wins overapps/app/.env.local, so this does not touch your development database.
Check: in Neon's SQL editor, select count(*) from pg_policies; returns more than zero, and select rolname from pg_roles where rolname = 'plotra_app'; returns one row. The migrations create that role; the app's row-level security depends on it.
2. Files: Cloudflare R2
Version snapshots and attachments must go to R2 in production. Without it they are stored in Postgres, and Neon's free storage would be gone within weeks.
- In the Cloudflare dashboard, open R2 and create two buckets:
plotrafor the app andplotra-backupsfor database dumps. Leave both private: no public access, nor2.devaddress. - Create two API tokens under R2 → Manage API tokens, each with Object Read & Write and limited to one bucket:
- one for
plotra, used by the app; - one for
plotra-backups, used by the backup workflow. Kept apart, a leaked app credential can't delete the backups.
- one for
- Note each token's access key ID and secret access key, and your Cloudflare account ID.
Check: both buckets are listed and empty.
3. The three Vercel projects
Vercel's Hobby plan can't be connected to a repository owned by a GitHub organization. The repository has to be under a personal account.
For each of apps/app, apps/web and apps/docs:
- Add New… → Project, import the repository, and set Root Directory to the app's folder. Leave "Include files outside the root directory" on: the shared packages live outside it.
- Leave the build settings alone. Each app has a
vercel.jsonthat sets the framework and the build command, and Vercel installs with Bun because the repository has abun.lock. - Add the environment variables below (Production environment), then deploy.
- Under Settings → Domains, add the app's address from the table above and create the DNS record Vercel asks for.
Vercel skips a project's build when a commit changes nothing it depends on. That is on by default for new projects (Settings → Build and Deployment → Root Directory → Skip deployment) and works with Bun workspaces, so a docs change doesn't rebuild the app.
apps/app
| Variable | Value |
|---|---|
DATABASE_URL | Neon's pooled connection string |
BETTER_AUTH_SECRET | the first secret you generated |
BETTER_AUTH_URL | https://app.plotra.itzsudhan.com, without a trailing slash |
COLLAB_SECRET | the second secret you generated |
NEXT_PUBLIC_COLLAB_URL | the collab worker's address. Set it in step 4, when you know it. |
R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY | the Cloudflare account ID and the app token from step 2 |
R2_BUCKET | plotra |
QUOTA_FILE_MB | 4. See the note below. |
NEXT_PUBLIC_SITE_URL | https://plotra.itzsudhan.com |
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET | from step 5 |
RESEND_API_KEY, EMAIL_FROM | from step 6 |
SENTRY_DSN | optional, see Error tracking |
AI_KEY_SECRET | optional: openssl rand -base64 32. Lets people save their own AI provider key; without it that feature is off. |
Uploads on Vercel stop at 4.5 MB
A Vercel Function can't receive a request body larger than 4.5 MB, and uploads go through one. Plotra's own default limit is 25 MB per file, which Vercel would reject with an error page before the app sees the file. Setting QUOTA_FILE_MB=4 makes the app say "Files can be up to 4 MB" instead. Larger files need uploads that go straight to R2, which is not built yet.
apps/web
| Variable | Value |
|---|---|
NEXT_PUBLIC_APP_URL | https://app.plotra.itzsudhan.com |
NEXT_PUBLIC_DOCS_URL | https://docs.plotra.itzsudhan.com |
NEXT_PUBLIC_SITE_URL | https://plotra.itzsudhan.com |
apps/docs
| Variable | Value |
|---|---|
NEXT_PUBLIC_DOCS_URL | https://docs.plotra.itzsudhan.com |
Variables that start with NEXT_PUBLIC_, and SENTRY_DSN, are read when the project is built. After changing one, redeploy.
Check:
curl -s $APP_URL/api/healthanswers with "ok":true, "database":"ok", "objectStore":true and an empty warnings list. collab, email and googleSignIn turn true as the next steps are done. The health endpoint only ever says whether a service is configured, never a value.
curl -sI $APP_URL/sign-in | grep -i -E "strict-transport|x-frame|x-content-type|referrer-policy"prints four headers.
4. Collab worker: Cloudflare Workers
The worker's production settings are the production environment in apps/collab/wrangler.jsonc.
-
If your app address is not
https://app.plotra.itzsudhan.com, changeAPP_URLunderenv.production.varsinapps/collab/wrangler.jsonc. It must equal the app'sBETTER_AUTH_URLexactly: the worker checks every token against it. -
Deploy, then give the worker its secret (the same
COLLAB_SECRETas the app):cd apps/collab bunx wrangler login bun run deploy # wrangler deploy --env production bunx wrangler secret put COLLAB_SECRET --env productionAlways deploy with
bun run deploy. A plainwrangler deployships the local settings, and the worker's health check then answers 503. -
The deploy prints the worker's address,
https://plotra-collab.<your-subdomain>.workers.dev. That address works as it is. To usehttps://collab.plotra.itzsudhan.cominstead, the domain's DNS zone has to be on Cloudflare: uncomment theroutesline inwrangler.jsoncand deploy again. -
In the Vercel project for
apps/app, setNEXT_PUBLIC_COLLAB_URLto the worker's address (withhttps://) and redeploy the app.
Check:
curl -s https://<worker address>/health # {"ok":true}and /api/health on the app now shows "collab":true.
5. Google sign-in
In the Google Cloud console, under Google Auth Platform:
-
Branding: app name, support email, the app's home page (
https://plotra.itzsudhan.com), a privacy policy link (https://plotra.itzsudhan.com/privacy) and terms link (https://plotra.itzsudhan.com/terms), and the authorized domain (the registrable domain,itzsudhan.com). -
Audience: External, and publish the app so it is "In production". While it is in testing, only listed test users can sign in. Plotra asks only for name, email and profile picture, which need no sensitive-scope review; Google may still ask to verify the branding before it shows the app's name and logo.
-
Clients → Create client → Web application:
- Authorized JavaScript origin:
https://app.plotra.itzsudhan.com - Authorized redirect URI:
https://app.plotra.itzsudhan.com/api/auth/callback/google
Keep a separate client for
http://localhost:3000rather than adding localhost to this one. - Authorized JavaScript origin:
-
Put the client ID and secret in the app's Vercel project as
GOOGLE_CLIENT_IDandGOOGLE_CLIENT_SECRET, and redeploy.
Check: the sign-in page shows "Continue with Google", and signing in with a Google account that is not yours works.
6. Email: Resend
- In Resend, Domains → Add domain. A subdomain kept for sending, such as
mail.plotra.itzsudhan.com, keeps the app's mail reputation apart from everything else on the domain. - Add the DNS records Resend lists: the SPF record (a TXT and an MX on the
sendsubdomain) and the DKIM record (a TXT atresend._domainkey). Add a DMARC record too if the domain has none: a TXT at_dmarcwithv=DMARC1; p=none;. - Wait for Resend to show the domain as verified, then create an API key with Sending access limited to that domain.
- In the app's Vercel project set
RESEND_API_KEY, andEMAIL_FROMto an address on the verified domain, for examplePlotra <hello@mail.plotra.itzsudhan.com>. Redeploy.
The free tier sends 3,000 emails a month and 100 a day. The app limits invitations and shares per person to stay well inside that.
Check: invite an address at another mail provider to a workspace. The mail arrives in the inbox, not in spam, and its headers ("Show original" in Gmail) say SPF: PASS and DKIM: PASS.
7. Repository secrets and variables
The repository has three workflows. ci.yml needs nothing. The other two do nothing until these exist, under Settings → Secrets and variables → Actions.
| Kind | Name | Value | Used by |
|---|---|---|---|
| Variable | APP_URL | https://app.plotra.itzsudhan.com | uptime.yml |
| Variable | COLLAB_URL | the worker's address, with https:// | uptime.yml |
| Variable | BACKUP_BUCKET | plotra-backups | backup.yml |
| Variable | BACKUP_KEEP | optional: how many dumps to keep. Default 14. | backup.yml |
| Variable | PG_MAJOR | optional: the database's Postgres major version. Default 18. | backup.yml |
| Secret | BACKUP_DATABASE_URL | Neon's direct connection string | backup.yml |
| Secret | R2_ACCOUNT_ID | the Cloudflare account ID | backup.yml |
| Secret | BACKUP_R2_ACCESS_KEY_ID | the backup token's access key ID | backup.yml |
| Secret | BACKUP_R2_SECRET_ACCESS_KEY | the backup token's secret access key | backup.yml |
With the GitHub CLI:
gh variable set APP_URL --body "$APP_URL"
gh variable set COLLAB_URL --body "https://<worker address>"
gh variable set BACKUP_BUCKET --body "plotra-backups"
gh secret set BACKUP_DATABASE_URL # paste when asked
gh secret set R2_ACCOUNT_ID
gh secret set BACKUP_R2_ACCESS_KEY_ID
gh secret set BACKUP_R2_SECRET_ACCESS_KEYThe uptime check runs every half hour, about 1,500 runner minutes a month. That is free in a public repository. A private one gets 2,000 minutes a month for everything, so leave APP_URL and COLLAB_URL unset until the repository is public.
A failed scheduled run emails the person who last edited the workflow file. Make sure GitHub may send it: your profile → Settings → Notifications → Actions, with email on for failed workflows.
Check: gh workflow run uptime.yml, then gh run watch. The run is green.
8. First backup, and a restore
gh workflow run backup.yml
gh run watchCheck: the run is green and plotra-backups holds one object under backups/.
Then restore it. A backup that has never been restored is a guess. The steps are in Backups and restore, and they take about fifteen minutes. Do it before inviting anyone.
Error tracking
Optional. Create a project in Sentry (the free plan is enough), copy its DSN, set SENTRY_DSN on the app's Vercel project and redeploy. Server errors and browser errors then arrive with a stack trace and the route they happened on. Request bodies, cookies and query strings are never sent, so no one's writing leaves the app in an error report. Without SENTRY_DSN nothing is sent and the browser loads no reporting code.
Check: open the app, open the browser console and run:
setTimeout(() => { throw new Error("Sentry test from the console") });The error appears in Sentry within a minute.
9. Smoke test, in two browsers
Use two different browsers (or one normal window and one private window), with two accounts: A and B. Every line should work. If one doesn't, stop and fix it before the next.
- A signs up with email and password. B signs in with Google.
- A creates a project, opens a scene and writes a paragraph. Reload the page: the paragraph is still there.
- A shares the project with B's email as an editor. The email arrives; B opens the link and sees the project.
- Both open the same scene. Each sees the other's cursor. Both type at once, in different paragraphs and then in the same one: both screens end up with the same text.
- B turns off the network, types a sentence, and turns it back on. The sentence reaches A.
- A saves a named version, changes the text, and restores the version. In the Cloudflare dashboard the
plotrabucket now has an object underversions/. - A creates a research note and attaches an image under 4 MB. B can open it. The bucket has an object under
attachments/. - B comments on a passage; A replies and resolves it.
- A changes B to a viewer. B can still read and can no longer type.
- A exports the project and opens the file.
- A switches on the read-only link in the Share dialog and opens it in a window that is not signed in.
curl -s $APP_URL/api/healthshows every servicetrueand no warnings.- In the Vercel project's logs for the last hour there is no line starting with
[plotra] WARNING.
When all of it passes, add the uptime variables if you held them back in step 7, and the hosted version is up.
After the first deploy
- App, site, docs: pushing to
masterdeploys them. Nothing to run. - Collab worker:
bun run deployinapps/collabwhenapps/collabchanges. It is not deployed automatically. - Database migrations: when a release adds one, run
DATABASE_URL="<direct connection string>" bun run db:migratebefore the push that needs it reachesmaster. - Postgres upgrade: when the database moves to a new major version, set the
PG_MAJORvariable to match, or the nightly backup fails.