Deploy one app to all six providers

Start state: any scaffolded app that runs locally. End state: the same app live on Cloudflare Pages, Vercel, Netlify, Surge, Heroku, and GitHub Pages — and a clear picture of what pnpm deploy actually does per host. The deploy DX contract: login flows launch for you, SPA fallbacks are written for you, and the provider choice is remembered per app.

Six hosts, one command.

1. See where you stand

pnpm deploy --providers

Expected: a status table —

provider        status
cloudflare      logged in     Cloudflare Pages
vercel          login needed  Vercel
netlify         login needed  Netlify
surge           login needed  Surge
heroku          CLI missing   Heroku (static buildpack)
github-pages    logged in     GitHub Pages

"login needed" is fine — each provider's official login flow launches interactively the first time you deploy to it. "CLI missing" means installing that provider's CLI first (heroku and gh are the two that need a real binary; the rest run via npx).

2. Rehearse with --dry-run

pnpm deploy launchpad --dry-run

Expected: the plan and the full SPA build run, but no network deploys happen — the last line reads "dry run complete — would deploy apps/launchpad/dist to Cloudflare Pages as geenius-mvp-launchpad". Use this to catch build breakage before touching a host.

3. Deploy to each host

The command shape never changes — only --to does:

pnpm deploy launchpad --to cloudflare
pnpm deploy launchpad --to vercel
pnpm deploy launchpad --to netlify
pnpm deploy launchpad --to surge
pnpm deploy launchpad --to heroku
pnpm deploy launchpad --to github-pages

For a Convex app, every run first pushes the backend (reusing the project recorded in .env.local — never a duplicate), builds the SPA against prod URLs, then hands the dist/ directory to the provider adapter. What each adapter does, including the SPA fallback it writes so client-side routes survive a hard refresh:

  • cloudflare — creates the Pages project if missing, wrangler pages deploy; no fallback file needed (the template ships public/_redirects) → https://<name>.pages.dev
  • vercel — writes vercel.json with a rewrite of /(.*) to /index.html, then vercel deploy --prod → *.vercel.app
  • netlify — writes _redirects ("/* /index.html 200"), sites:create then deploy --prod → *.netlify.app
  • surge — copies index.html to 200.html (Surge's SPA convention), surge <dir> <name>.surge.sh
  • heroku — stages dist/ with static.json (routes /** → index.html) + the heroku-community/static buildpack, tars it, and drives the Heroku Build API; polls until the build succeeds → *.herokuapp.com
  • github-pages — copies index.html to 404.html (the Pages SPA trick) and writes .nojekyll, force-pushes a gh-pages branch, enables Pages via the API → https://<owner>.github.io/<name>/
After any Convex deploy, SITE_URL on the prod deployment is synced to the real frontend URL — that is what makes sign-in work on the deployed origin. Deploying to a new provider re-syncs it, so the LAST host you deployed to is the one where auth works. One production backend, one canonical frontend origin at a time.

4. The memory file

cat apps/launchpad/.deploy.json
# { "provider": "github-pages", "url": "https://you.github.io/geenius-mvp-launchpad/" }

The last provider and URL are remembered per app — from now on a bare pnpm deploy launchpad goes back to the same host. Every run also appends to tmp-deploy-summary.txt at the repo root, one line per app: slug | url | backend.

What you built

  • The same app live on six hosts, each with the correct SPA fallback written automatically
  • A rehearsal habit: --dry-run to validate the build before any network call
  • Per-app deploy memory: --to once, then just pnpm deploy <slug> forever