Plotra Docs
Deploying

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.

WhatAddressRuns on
Marketing site (apps/web)https://plotra.itzsudhan.comVercel
App (apps/app)https://app.plotra.itzsudhan.comVercel
Docs (apps/docs)https://docs.plotra.itzsudhan.comVercel
Collab worker (apps/collab)https://collab.plotra.itzsudhan.com, or its workers.dev addressCloudflare 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.$DOMAIN

Generate 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 worker

1. Database: Neon

  1. 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).

  2. From Connect, copy two connection strings:

    • the pooled one (its host contains -pooler): the app's DATABASE_URL;
    • the direct one (pooling switched off): for migrations and for backups.
  3. Run the migrations from the repository root, against the direct string:

    bun install
    DATABASE_URL="<direct connection string>" bun run db:migrate

    A DATABASE_URL in the environment wins over apps/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.

  1. In the Cloudflare dashboard, open R2 and create two buckets: plotra for the app and plotra-backups for database dumps. Leave both private: no public access, no r2.dev address.
  2. 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.
  3. 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:

  1. 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.
  2. Leave the build settings alone. Each app has a vercel.json that sets the framework and the build command, and Vercel installs with Bun because the repository has a bun.lock.
  3. Add the environment variables below (Production environment), then deploy.
  4. 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

VariableValue
DATABASE_URLNeon's pooled connection string
BETTER_AUTH_SECRETthe first secret you generated
BETTER_AUTH_URLhttps://app.plotra.itzsudhan.com, without a trailing slash
COLLAB_SECRETthe second secret you generated
NEXT_PUBLIC_COLLAB_URLthe collab worker's address. Set it in step 4, when you know it.
R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEYthe Cloudflare account ID and the app token from step 2
R2_BUCKETplotra
QUOTA_FILE_MB4. See the note below.
NEXT_PUBLIC_SITE_URLhttps://plotra.itzsudhan.com
GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRETfrom step 5
RESEND_API_KEY, EMAIL_FROMfrom step 6
SENTRY_DSNoptional, see Error tracking
AI_KEY_SECREToptional: 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

VariableValue
NEXT_PUBLIC_APP_URLhttps://app.plotra.itzsudhan.com
NEXT_PUBLIC_DOCS_URLhttps://docs.plotra.itzsudhan.com
NEXT_PUBLIC_SITE_URLhttps://plotra.itzsudhan.com

apps/docs

VariableValue
NEXT_PUBLIC_DOCS_URLhttps://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/health

answers 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.

  1. If your app address is not https://app.plotra.itzsudhan.com, change APP_URL under env.production.vars in apps/collab/wrangler.jsonc. It must equal the app's BETTER_AUTH_URL exactly: the worker checks every token against it.

  2. Deploy, then give the worker its secret (the same COLLAB_SECRET as the app):

    cd apps/collab
    bunx wrangler login
    bun run deploy                                         # wrangler deploy --env production
    bunx wrangler secret put COLLAB_SECRET --env production

    Always deploy with bun run deploy. A plain wrangler deploy ships the local settings, and the worker's health check then answers 503.

  3. The deploy prints the worker's address, https://plotra-collab.<your-subdomain>.workers.dev. That address works as it is. To use https://collab.plotra.itzsudhan.com instead, the domain's DNS zone has to be on Cloudflare: uncomment the routes line in wrangler.jsonc and deploy again.

  4. In the Vercel project for apps/app, set NEXT_PUBLIC_COLLAB_URL to the worker's address (with https://) 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:

  1. 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).

  2. 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.

  3. 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:3000 rather than adding localhost to this one.

  4. Put the client ID and secret in the app's Vercel project as GOOGLE_CLIENT_ID and GOOGLE_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

  1. 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.
  2. Add the DNS records Resend lists: the SPF record (a TXT and an MX on the send subdomain) and the DKIM record (a TXT at resend._domainkey). Add a DMARC record too if the domain has none: a TXT at _dmarc with v=DMARC1; p=none;.
  3. Wait for Resend to show the domain as verified, then create an API key with Sending access limited to that domain.
  4. In the app's Vercel project set RESEND_API_KEY, and EMAIL_FROM to an address on the verified domain, for example Plotra <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.

KindNameValueUsed by
VariableAPP_URLhttps://app.plotra.itzsudhan.comuptime.yml
VariableCOLLAB_URLthe worker's address, with https://uptime.yml
VariableBACKUP_BUCKETplotra-backupsbackup.yml
VariableBACKUP_KEEPoptional: how many dumps to keep. Default 14.backup.yml
VariablePG_MAJORoptional: the database's Postgres major version. Default 18.backup.yml
SecretBACKUP_DATABASE_URLNeon's direct connection stringbackup.yml
SecretR2_ACCOUNT_IDthe Cloudflare account IDbackup.yml
SecretBACKUP_R2_ACCESS_KEY_IDthe backup token's access key IDbackup.yml
SecretBACKUP_R2_SECRET_ACCESS_KEYthe backup token's secret access keybackup.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_KEY

The 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 watch

Check: 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.

  1. A signs up with email and password. B signs in with Google.
  2. A creates a project, opens a scene and writes a paragraph. Reload the page: the paragraph is still there.
  3. A shares the project with B's email as an editor. The email arrives; B opens the link and sees the project.
  4. 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.
  5. B turns off the network, types a sentence, and turns it back on. The sentence reaches A.
  6. A saves a named version, changes the text, and restores the version. In the Cloudflare dashboard the plotra bucket now has an object under versions/.
  7. A creates a research note and attaches an image under 4 MB. B can open it. The bucket has an object under attachments/.
  8. B comments on a passage; A replies and resolves it.
  9. A changes B to a viewer. B can still read and can no longer type.
  10. A exports the project and opens the file.
  11. A switches on the read-only link in the Share dialog and opens it in a window that is not signed in.
  12. curl -s $APP_URL/api/health shows every service true and no warnings.
  13. 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 master deploys them. Nothing to run.
  • Collab worker: bun run deploy in apps/collab when apps/collab changes. It is not deployed automatically.
  • Database migrations: when a release adds one, run DATABASE_URL="<direct connection string>" bun run db:migrate before the push that needs it reaches master.
  • Postgres upgrade: when the database moves to a new major version, set the PG_MAJOR variable to match, or the nightly backup fails.

On this page