Two apps, two deploys. The web app is a TanStack Start server build, and the api is a long-lived Node process. Vite produces Nitro's Node output for the web app. Railway runs that output directly; Vercel recognizes the checked-in TanStack Start framework configuration.
The api cannot go on Vercel's serverless functions as it stands. It is an Express app expecting to stay up, with a timer that sweeps expired rate-limit windows.
-
Deploy the api first
The web app needs the api's URL, and the api needs the web app's, so one of them gets a placeholder. Deploying the api first means only one value is provisional.
Point your host at the repository and set the start command to:
pnpm --filter @ffd/api startThere is no build step. The api runs under
tsxin production, sopnpm installis the whole of the build.Give it every variable from Environment variables, plus
NODE_ENV=productionif your host does not set it. Note the URL it comes up on. -
Migrate the production database
NODE_ENV=production pnpm db:migrateRun it against production deliberately, from your machine or from a one-off job. Nothing migrates at boot, so if you skip this the api starts and every query fails against a database with no tables.
Add it to your release pipeline as a step that runs before the new instances take traffic.
-
Deploy the web app
Give the web service the
VITE_entries fromapps/web/.env.exampleplusCLERK_SECRET_KEY.VITE_POSTHOG_PROJECT_TOKENmay remain blank.VITE_API_URLis the URL from the first step, with no trailing slash.Everything prefixed
VITE_is statically replaced in browser code at build, so changing one of those values means a rebuild rather than a restart.CLERK_SECRET_KEYis read only by the server output.When PostHog is enabled, the web service itself proxies
/ffd-ph-gestto the regional PostHog hosts selected byVITE_POSTHOG_REGION. No DNS or second proxy deployment is required.On Vercel, set the project root directory to
apps/webso the checked-invercel.jsonframework setting applies. On Railway, keep the repository root and build and start the web service with:pnpm --filter @ffd/web build pnpm --filter @ffd/web startThe start command runs
node .output/server/index.mjs. Both services need Node 22.12.0 or newer.To inspect the Vercel-shaped artifact locally, build with the same Nitro preset:
NITRO_PRESET=vercel pnpm --filter @ffd/web buildThat output is for Vercel's functions. Use the ordinary build above for Railway's Node process.
-
Close the loop on the api
Now that the web app has a domain, set the two values on the api that name it:
WEB_URL=https://yourdomain.com CORS_ORIGINS=https://yourdomain.com,https://www.yourdomain.comThey are not interchangeable.
CORS_ORIGINSis the allowlist of origins permitted to call the api.WEB_URLis the single origin that Stripe Checkout success and cancel URLs are built from.Add every preview domain to
CORS_ORIGINStoo, or preview deploys answer every request with a CORS failure, and browser CORS errors do not say which side is wrong. -
Repoint the webhooks
Both webhook endpoints currently point at a tunnel or at nothing. Update them to the deployed api:
Provider Endpoint Secret goes in Stripe https://<api-host>/stripe/webhookSTRIPE_WEBHOOK_SECRETClerk https://<api-host>/clerk/webhookCLERK_WEBHOOK_SECRETEach environment's endpoint has its own signing secret. The one from
stripe listenis not the one from the dashboard endpoint, and the sandbox endpoint's is not the live one's. -
Check both ends
curl https://<api-host>/health{ "status": "ok", "service": "api", "uptimeSeconds": 31, "database": "postflare", "stripeEnv": "live" }databaseshould be your production name rather than the_devone, andstripeEnvshould matchVITE_STRIPE_ENVin the web app's build. Those are the two environment axes, and this endpoint is the one place they are visible together.
The variables, split by app
The api gets business integration secrets. Configure its database, Stripe, webhook, Resend and GitHub credentials there. It also uses a Clerk secret for its identity boundary.
The web app gets no secret except CLERK_SECRET_KEY, which @clerk/tanstack-react-start needs server side to verify a session before paint. Everything else it reads is prefixed VITE_ and ships to the browser by design.
What breaks first
A preview domain missing from CORS_ORIGINS. Every api call fails, the dashboard renders empty, and the browser console blames CORS without saying which origin was rejected. Add previews as you create them, or use a wildcard-free list of the ones you actually open.
A cold api on the checkout path. The web app renders pricing in the request's language, but the button posts to the separate api. If your host sleeps that process, the first buyer of the day waits for a boot.
NODE_ENV=production on a staging deploy. Most platforms set it, including on previews, so a staging api writes to the production database until you give it its own DB_NAME. The boot log names the resolved database, which is where you will see it.
A changed VITE_STRIPE_ENV with no rebuild. The old value is still compiled into the bundle. The symptom is a pricing page showing offers the api refuses with stripe_env_mismatch.
A PostHog project and region that disagree. Events are sent to the wrong regional ingestion host. Set VITE_POSTHOG_REGION to eu or us for the selected project and rebuild.
What is not here
No CDN configuration, no image optimization tuning, no autoscaling policy. Those are your product's decisions, and a default that suits FFD's traffic would be the wrong default for yours.
No health check for the web app. TanStack Start renders localized pages for each request, so the web process must remain available after the build is deployed. Configure a host-level HTTP check for your deployment.
No blue-green migration story. pnpm db:migrate runs forward-only migrations, and a migration that is not backward compatible with the running instances will break them during a rolling deploy. If that matters to you, split the migration in two: add the column, deploy the code, then drop the old one.
Related
- Environment variables, the full list
- Architecture, why there are two deploys
docs/adr/0023-two-databases-in-one-neon-project.md,NODE_ENVand previews
