Deploying the hosted version
The checklist for putting Plotra on Vercel, Cloudflare, Neon and R2, and switching on paid plans with Creem.
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 except Plans and billing fits in a free tier. What each one allows, and what it would cost past that, is in the README.
Paid plans end the free hosting
Vercel's Hobby plan is for non-commercial use. Once the Pro checkout is live, the app has to be on Vercel Pro (about $20 a month) or on another host. Do Plans and billing only after that move. Until CREEM_API_KEY is set there are no plans, nothing is sold, and Hobby is allowed.
Addresses
The page is written for one domain, plotra.ink. 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.ink | Vercel |
App (apps/app) | https://app.plotra.ink | Vercel |
Docs (apps/docs) | https://docs.plotra.ink | Vercel |
Collab worker (apps/collab) | https://collab.plotra.ink, 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.ink
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. (On Vercel Pro, which paid plans need, that restriction goes away.)
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.ink, 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. |
S3_ENDPOINT | https://<account ID>.r2.cloudflarestorage.com, with the Cloudflare account ID from step 2 |
S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY | the app token from step 2 |
S3_BUCKET | plotra |
NEXT_PUBLIC_SITE_URL | https://plotra.ink |
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET | from step 5 |
SMTP_URL, 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. |
CREEM_…, AI_… | optional, see Plans and billing. Without CREEM_API_KEY there are no plans at all. |
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. Vercel would reject a larger file with an error page before the app sees it, so on Vercel the app limits files to 4 MB itself and says "Files can be up to 4 MB" instead. Larger files need uploads that go straight to R2, which is not built yet. Until then the file sizes the plans name (10 MB on Free, 100 MB on Pro) can't be reached on Vercel.
apps/web
| Variable | Value |
|---|---|
NEXT_PUBLIC_APP_URL | https://app.plotra.ink |
NEXT_PUBLIC_DOCS_URL | https://docs.plotra.ink |
NEXT_PUBLIC_SITE_URL | https://plotra.ink |
apps/docs
| Variable | Value |
|---|---|
NEXT_PUBLIC_DOCS_URL | https://docs.plotra.ink |
NEXT_PUBLIC_APP_URL | https://app.plotra.ink |
NEXT_PUBLIC_SITE_URL | https://plotra.ink |
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.ink, 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.inkinstead, 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.ink), a privacy policy link (https://plotra.ink/privacy) and terms link (https://plotra.ink/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.ink - Authorized redirect URI:
https://app.plotra.ink/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: SMTP
Plotra sends email over SMTP, so any mail service works. These steps use Resend.
- In Resend, Domains → Add domain. A subdomain kept for sending, such as
mail.plotra.ink, 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. It is the SMTP password.
- In the app's Vercel project set
SMTP_URLtosmtps://resend:<the API key>@smtp.resend.com:465, andEMAIL_FROMto an address on the verified domain, for examplePlotra <hello@mail.plotra.ink>. Redeploy.
Resend's 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.ink | 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 | S3_ENDPOINT | https://<account ID>.r2.cloudflarestorage.com | backup.yml |
| Secret | BACKUP_S3_ACCESS_KEY_ID | the backup token's access key ID | backup.yml |
| Secret | BACKUP_S3_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 S3_ENDPOINT
gh secret set BACKUP_S3_ACCESS_KEY_ID
gh secret set BACKUP_S3_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.
Plans and billing: Creem
Optional, and the last thing to switch on. Without CREEM_API_KEY the app has no plans: no Free or Pro, no checkout, no Plan & billing tab. What the plans are is in Plans and billing.
Before you start, move the app's Vercel project to Vercel Pro, or to another host. See the note at the top of this page.
- Run the migration that adds the billing tables, if the database was created before it existed:
DATABASE_URL="<direct connection string>" bun run db:migrate. - In Creem, switch to test mode and create two subscription products: Pro monthly at $10, and Pro yearly at $96. Note each product's id.
- Create a test API key. It starts with
creem_test_, and the app then talks to Creem's test API. - Register a webhook at
https://app.plotra.ink/api/billing/webhookand copy its signing secret. - Set these on the app's Vercel project and redeploy:
| Variable | Value |
|---|---|
CREEM_API_KEY | the API key. One that starts with creem_test_ uses Creem's test API |
CREEM_WEBHOOK_SECRET | the webhook's signing secret |
CREEM_PRODUCT_PRO_MONTHLY | the Creem product id of the monthly product |
CREEM_PRODUCT_PRO_YEARLY | the Creem product id of the yearly product |
For hosted AI, the model Pro workspaces use with their credits, also set:
| Variable | Value |
|---|---|
AI_PROVIDER | the provider: anthropic, openai, openrouter or compatible |
AI_API_KEY | the project's own key at that provider. These calls are on the project's bill |
AI_BASE_URL | only for a provider that needs a URL |
AI_MODEL_AGENT, AI_MODEL_FAST | the model for agent runs, and the cheaper one for inline actions |
All of these are optional. Without the AI_ values, Pro has no hosted AI and the agent runs on people's own keys only.
Check, in test mode: open a workspace's settings at /w/<slug>/settings?tab=billing, upgrade with one of Creem's test cards, and see the workspace on Pro when you come back. Cancel it, and see that it says Pro runs until the end of the period. In Creem's dashboard the webhook deliveries show as successful.
- When that works, repeat steps 2 to 5 in Creem's live mode: live products, a live API key, a live webhook and its secret. Name the hosted AI provider on the site's privacy page before the first person pays.
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.
Plotra Docs