TypeVoice — Deployment Guide
The app runs fully keyless in development. Production is deliberately small — 6 services, ~21 env vars, and every integration is already coded and env-gated:
| Service | Job | Why this one |
| Railway | App + Postgres + domain | One dashboard for compute, data, deploys, and typevoice.ai |
| Supabase | Auth only (magic link + Google) | Already coded in lib/auth.ts/proxy.ts; free tier; no user data lives there — our DB is Railway |
| Cloudflare R2 | Audio storage | Zero egress fees — audio playback is the main bandwidth cost |
| OpenRouter | Transcribe / extract / score | Provider fully implemented, inert until keyed; one key, models swappable by env |
| Stripe | Billing | Checkout + Portal + webhook already coded |
| Resend | Notification send fully coded, inert until keyed |
The map: env var → what the stub does when it's absent
| Env var(s) | Service | Behavior when absent |
DATABASE_URL | Railway Postgres | SQLite file at prisma/dev.db — always works |
NEXT_PUBLIC_SUPABASE_URL, NEXT_PUBLIC_SUPABASE_ANON_KEY | Supabase Auth | Dev-stub session: auto signed-in seeded owner |
AI_PROVIDER, OPENROUTER_API_KEY | OpenRouter | Mock: no transcripts/scores on real recordings, honest violet card; audio playback fine |
STORAGE_PROVIDER, R2_* (4) | Cloudflare R2 | Local disk → .data/ |
RESEND_API_KEY, EMAIL_FROM | Resend | Notifications logged to console (settings persist and apply once live) |
STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET, STRIPE_PRICE_* (4) | Stripe | Billing page visual-only, honest modal on upgrade buttons |
NEXT_PUBLIC_APP_URL | — | http://localhost:3000 |
IP_HASH_SALT | — | "dev-salt" fallback — set a long random string in prod |
TRUST_PROXY | — | Unset: x-forwarded-for ignored (spoofable without a proxy). Set 1 on Railway. |
Nothing else. Inngest and Upstash from the original spec are not used — see "When you scale" at the bottom.
1. Railway — app, Postgres, domain
- New project at railway.com → "Deploy from GitHub repo". The builder auto-detects Next.js (
npm run build/npm start;next startrespects Railway's injectedPORT). - Right-click the canvas → add a PostgreSQL service. On the app service, set
DATABASE_URL=${{Postgres.DATABASE_URL}}(a reference variable — stays correct if credentials rotate). - Switch Prisma to Postgres: in
prisma/schema.prismachangeprovider = "sqlite"→"postgresql", thennpx prisma migrate dev --name initagainst the Railway DB (dev useddb push, so fresh migrations generate cleanly). Addnpx prisma migrate deployas the pre-deploy command. Optional:npm run db:seedfor the demo forms. - Set the remaining env vars from
.env.examplein the Variables tab:NEXT_PUBLIC_APP_URL=https://typevoice.ai,TRUST_PROXY=1, a realIP_HASH_SALT, and the service keys from the sections below. - Settings → Networking → add custom domain
typevoice.ai; point the DNS records Railway shows you (CNAME, or ALIAS/ANAME at the apex).
Platform notes:
next/server after()(the AI-pipeline trigger) is *more* reliable here than on serverless — one long-lived Node process, no post-response freeze. No job queue needed.- The container disk is ephemeral across deploys — the local-disk audio stub does not survive redeploys. Do the R2 swap (§3) before real applicants, or attach a Railway Volume at
.data/as a stopgap. - String-enum and string-JSON columns port to Postgres as-is (that's why they're strings). Promoting them to native enums/
Jsonis an optional later refactor — every read already goes throughlib/json.ts.
2. Supabase — auth only (magic link + Google)
Already coded (lib/auth.ts, lib/supabase/*, proxy.ts, /login, /auth/callback). Supabase holds no product data — it only issues sessions; users map by email into our Postgres on first login (trial plan, 14 days, founding flag while under the cap).
- Create a project at supabase.com (the bundled Postgres goes unused — that's fine).
- Authentication → Sign In / Up: enable Email (magic link) and Google (OAuth credentials from Google Cloud Console; authorized redirect URI is your Supabase callback URL, shown in the provider form).
- Authentication → URL Configuration: Site URL
https://typevoice.ai, and addhttps://typevoice.ai/auth/callbackto Redirect URLs. - Set
NEXT_PUBLIC_SUPABASE_URLandNEXT_PUBLIC_SUPABASE_ANON_KEY(Project Settings → API). - New users land in the onboarding wizard automatically (
onboardedAt = null).
3. Cloudflare R2 — audio storage
Zero code changes. R2Storage is implemented in lib/storage.ts (presigned 15-min playback URLs, private bucket) and inert until keyed.
- Cloudflare dashboard → R2 → create bucket
typevoice-audio(private). Create an API token with object read/write. - Set
STORAGE_PROVIDER=r2+ the fourR2_*vars.
The authed /api/answers/[answerId]/audio route keeps working as a streaming proxy, or you can switch the player src to getPlaybackUrl() presigned URLs to serve straight from R2.
4. OpenRouter — the AI pipeline
Zero code changes. lib/ai/openai.ts ships fully implemented (raw fetch, no SDK) and inert. OpenRouter's API is OpenAI-compatible for both endpoints we use — including /audio/transcriptions.
- Create an API key at openrouter.ai and load a few dollars of credits.
- Set
AI_PROVIDER=openrouterandOPENROUTER_API_KEY=sk-or-…. - Done. New submissions transcribe (
openai/gpt-4o-mini-transcribe), extract per your hints, summarize, and score (openai/gpt-4o-mini, JSON mode, zod-validated, clamped 1–100). The prompts live inlib/ai/prompts.ts; the models swap viaOPENROUTER_STT_MODEL/OPENROUTER_CHAT_MODELwithout a deploy — useful for A/B-ing cheaper STT (e.g.openai/whisper-large-v3-turbo) or a smarter scorer later.
(AI_PROVIDER=openai + OPENAI_API_KEY still works if you ever want to go direct.)
Unit economics: ~5 min of audio per submission ≈ $0.015 STT + <$0.01 LLM ≈ ~$0.03/submission COGS (OpenRouter passes through provider pricing; their ~5% fee is on credit top-ups). 500 submissions on Pro ≈ $15 against $99. 100 on Starter ≈ $3 against $49. Fine.
To re-run AI on an old submission, use the Retry button on a failed row, or flip a submission's status to failed and retry — the pipeline resumes from the first missing artifact and never re-transcribes what it already has.
5. Resend — email
Zero code changes. The send is implemented in lib/email.ts (raw fetch, no SDK; errors are logged, never break the pipeline) and inert until keyed.
- Create an API key at resend.com and verify typevoice.ai as a sending domain.
- Set
RESEND_API_KEYand[email protected].
The pipeline calls it when a score clears the owner's threshold; the notification settings UI persists notifyEnabled/notifyThresholdDefault.
6. Stripe — billing
Already coded: app/api/billing/checkout, app/api/billing/portal, app/api/webhooks/stripe, and the billing page switches from the honest modal to real Checkout the moment keys exist.
- Create Products/Prices: Starter $49/mo, Pro $99/mo, plus founding prices $39/$79 (monthly only, no lifetime deals).
- Set
STRIPE_SECRET_KEYand the fourSTRIPE_PRICE_*ids. - Dashboard → Webhooks → add endpoint
https://typevoice.ai/api/webhooks/stripewith eventscheckout.session.completed,customer.subscription.updated,customer.subscription.deleted; setSTRIPE_WEBHOOK_SECRETfrom it. (Locally:stripe listen --forward-to localhost:3000/api/webhooks/stripe.) - Trial expiry behavior (already the product rule): forms stay live and recordings are still captured — new submissions just queue as
processingDeferreduntil subscribed. Never hold a recording hostage.
When you scale (not before)
- Multiple Railway replicas → the in-memory rate limiter stops covering all traffic; reimplement
lib/ratelimit.ts#rateLimitover Upstash Redis (keep the signature — every caller stays untouched). - Pipeline observability/retries across deploys → move the
after()trigger to Inngest: addapp/api/inngest/route.ts, replaceafter(() => runPipeline(...))in the complete/retry routes withinngest.send(...), reuselib/pipeline.tsas-is. Keep the atomicclaimForProcessingguard — it's what makes retries safe.
Post-launch checklist
- [ ]
IP_HASH_SALTis a long random string;TRUST_PROXY=1 - [ ] R2 bucket is private; playback via presigned URLs or the authed proxy only
- [ ] The demo form (
/f/demo) and sample dashboard stay excluded from caps/stats (isDemo/isSeed— already enforced) - [ ]
/demo/dashboardis public by design (pinned to seeded sample data) — verify it renders for signed-out visitors after the auth swap; audio playback there needs either public-read seed objects or the presigned-URL path - [ ] Delete-a-submission hard-deletes storage objects (already implemented) — your GDPR path