All pages

Deploy

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 holds an in-memory rate limiter, and it is an Express app expecting to stay up.

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

    There is no build step. The api runs under tsx in production, so pnpm install is the whole of the build.

    Give it every variable from Environment variables, plus NODE_ENV=production if your host does not set it. Note the URL it comes up on.

  2. Migrate the production database

    NODE_ENV=production pnpm db:migrate

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

  3. Deploy the web app

    Give the web service the VITE_ entries from apps/web/.env.example plus CLERK_SECRET_KEY. VITE_POSTHOG_PROJECT_TOKEN may remain blank. VITE_API_URL is 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_KEY is read only by the server output.

    When PostHog is enabled, the web service itself proxies /ffd-ph-gest to the regional PostHog hosts selected by VITE_POSTHOG_REGION. No DNS or second proxy deployment is required.

    On Vercel, set the project root directory to apps/web so the checked-in vercel.json framework 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 start

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

    That output is for Vercel's functions. Use the ordinary build above for Railway's Node process.

  4. 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.com

    They are not interchangeable. CORS_ORIGINS is the allowlist of origins permitted to call the api. WEB_URL is the single origin that Stripe Checkout success and cancel URLs are built from.

    Add every preview domain to CORS_ORIGINS too, or preview deploys answer every request with a CORS failure, and browser CORS errors do not say which side is wrong.

  5. 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/webhook STRIPE_WEBHOOK_SECRET
    Clerk https://<api-host>/clerk/webhook CLERK_WEBHOOK_SECRET

    Each environment's endpoint has its own signing secret. The one from stripe listen is not the one from the dashboard endpoint, and the sandbox endpoint's is not the live one's.

  6. Check both ends

    curl https://<api-host>/health
    {
      "status": "ok",
      "service": "api",
      "uptimeSeconds": 31,
      "database": "ffd",
      "stripeEnv": "live"
    }

    database should be your production name rather than the _dev one, and stripeEnv should match VITE_STRIPE_ENV in 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.

Updated