Deployment
Where each piece ships and how
Three pieces ship; each on its own cadence.
Mobile app (iOS + Android)
Built and distributed via EAS — Expo Application Services, the official build/release system for Expo apps.
Profiles
Configuration: apps/mobile/eas.json. Four profiles:
| Profile | Purpose | Distribution |
|---|---|---|
development | Local dev builds (current operator's machine) | internal |
preview | Internal-share builds for stakeholder review | internal |
staging | TestFlight + Play internal track | store, staging channel |
production | Public App Store + Play Store | store, production channel |
Build + submit commands
# Staging (TestFlight)
pnpm build:staging --platform ios
pnpm submit:staging:ios
# Production
pnpm build:prod --platform ios
pnpm submit:iosEAS handles compiling the native binaries. Apple requires a Mac to build iOS; EAS provides one. After EAS finishes a build, the same toolchain submits to TestFlight or Play internal track using API keys configured in eas.json.
Over-the-air (OTA) updates
For JavaScript-only fixes between full builds, publish OTA via Expo Updates:
pnpm update:staging # OTA to staging channel
pnpm update:prod # OTA to production channelExisting installed apps pull the JS update on next launch — no App Store review. Native changes (new permissions, new native modules, config plugin changes) still require a full rebuild.
Store-side workflows
Two CLIs handle work EAS doesn't cover:
asc— App Store Connect (TestFlight beta groups, metadata, screenshots, IAP, reviewer responses, device registration).gplay— Google Play Console (listing updates, screenshots, staged rollouts, review responses).
Wrapped via pnpm asc / pnpm gplay. Full setup at apps/mobile/PUBLISHING.md. Both use API keys only — no interactive 2FA via web console.
Credentials
- Apple ASC API key:
apps/mobile/keys/AuthKey_68Y3V9WSG7.p8(gitignored). Configured ineas.jsonsubmit profiles viaascApiKeyIssuerId+ascApiKeyId. - Google Play service account:
apps/mobile/keys/google-service-account.json(gitignored). Configured ineas.jsonsubmit profiles. - Firebase config (Android push):
apps/mobile/google-services.json(gitignored).
All three files live on the operator's machine. Bitwarden holds copies. No file lives in GitHub.
Marketing website
Vercel. Repo linked to a Vercel project. Every push to main triggers an automatic production deploy in roughly 60–90 seconds. Pull requests get automatic preview URLs. There is no manual deploy step.
- Domain:
wearenxt.com(DNS pointed at Vercel). - Analytics + speed monitoring: Vercel Analytics + Vercel Speed Insights wired in (
@vercel/analytics,@vercel/speed-insights). Free tier; well within limits. - Env vars: managed in the Vercel project's Environment Variables settings. The full required list lives in
apps/web/.env.example.
What ships to web
apps/web/app/(marketing)/ — public marketing routes (home, About, Contact, privacy/terms, account-deletion request). There is no (authenticated) route group today.
apps/web/proxy.ts runs middleware on the Node runtime. If a future change needs Edge runtime, that goes in middleware.ts — Next 16 splits the two by file name.
Backend (Convex)
Convex Cloud. Fully managed.
# Dev — push code to dev deployment on save
pnpm --filter @app/backend dev
# Production — deploy
pnpm --filter @app/backend deploy
# or:
cd packages/backend && npx convex deployThere is no "deploy" run manually during normal development. The production deploy runs from a local machine today. Moving it to GitHub Actions is a one-day task; see Roadmap.
Convex auto-scales reads, writes, subscriptions, and crons. No scaling configuration to manage. No database backups to script (snapshots are automatic; see Data).
Docs site (this site)
Vercel. This Fumadocs app deploys as a separate Vercel project. Public — no auth gate. <meta name="robots" content="noindex"> keeps it out of search engines so it doesn't compete with the marketing site for query terms.
Deploy: same push-to-main model as the marketing site, but a separate Vercel project.
Staging vs production
- Mobile: staging = TestFlight, production = App Store. Two separate Expo channels.
- Web (marketing): Vercel preview URLs per-PR; production =
wearenxt.com. No long-lived staging URL. - Backend: no separate staging Convex deployment. Both staging and production mobile builds point at production Convex. Signed-in test accounts provide staging coverage.
- Docs (this site): Vercel preview per-PR + a single production URL. Public, noindex.
Skipping a dedicated staging Convex was deliberate. A staging backend doubles operator burden (two sets of crons firing, two sets of credentials, two sets of webhook endpoints) without catching anything the dev deployment doesn't already catch. Re-add later if the cost of a bad staging→prod surprise exceeds the cost of running a parallel backend.
Environment variables
A full inventory with comments lives in .env.example at the repo root. Server-side secrets that touch Convex are set via npx convex env set <key> <value> against the production deployment.
See Credentials for the canonical service-by-service list.
Technical detail
EAS build caches
EAS caches pnpm install output between builds. A pnpm-lock.yaml change invalidates the cache. Custom config plugins (app.config.ts) also invalidate. Average build time after warm cache: ~6 minutes iOS, ~5 minutes Android. Cold cache: add ~4 minutes per platform.
Vercel previews and Convex
Web previews on Vercel use the production Convex deployment. Read-only marketing site; no risk of preview traffic polluting production data. If a future change adds authenticated web routes, branch-scoped Convex preview deployments need to be wired (Convex supports it via the Convex Vercel integration).
Next 16 specifics
apps/web/proxy.ts= Node runtime.middleware.ts= Edge runtime. Don't use Edge-only APIs inproxy.ts.params/searchParams/cookies()/headers()are async. Alwaysawait.redirect()/notFound()throw internally — never wrap intry/catch.cacheComponentsis OFF. Opt in by wrapping every client page that readsuseQuery/useAction/useI18nat module load in<Suspense>at the data boundary.- Use
connection()beforeprocess.envreads in dynamic code (replacesunstable_noStore). - Use
after()fromnext/serverfor fire-and-forget post-response work (analytics writes, audit logs).
Convex deploys are transactional
npx convex deploy is atomic. Either the whole deploy lands (functions, schema migration, cron registration) or none of it does. There is no half-deployed state to recover from. The trade-off: a schema change incompatible with in-flight traffic must be done in two deploys (add → backfill → switch reader → remove). This is what convex/migrations.ts is for.