SaaS Starter
Deploy

Railway

Deploy the boilerplate (server + client + Postgres + Redis) to Railway in four services with config-as-code.

The repo ships two railway.toml files so Railway picks up the right build config for each half of the stack:

FileWhat it builds
railway.toml (root)The Bun + Express API (apps/server/Dockerfile)
apps/client/railway.tomlThe Next.js frontend (apps/client/Dockerfile)

A complete deploy is four Railway services in one project: the API, the web app, and Railway's managed Postgres + Redis plugins.

[!WARNING] The first deploy will fail. That's expected. The server runs strict Zod validation against DATABASE_URL, BETTER_AUTH_SECRET, and BETTER_AUTH_URL at boot — without those it crashes before the HTTP listener binds, and Railway's healthcheck on /health reports "service unavailable". Provision the plugins and set the secrets (steps 1–3 below), then trigger a redeploy from the dashboard. Subsequent deploys are fast.

Three paths

Pick the one that matches how you like to work:

PathUXWhen to use
A — Deploy on Railway buttonone click in the browseronce someone has published a template from this repo (see README placeholder)
B — bun run deploy:railwayone command on your machineyou have the Railway CLI installed; want everything wired in ~2 minutes
C — Manualdashboard clicksyou want to understand each step or customize as you go

Path A — one-click button

The repo's README has a [![Deploy on Railway]](…) button. Once a template is published from a working deploy of this project, that button URL points to the template, and clicking it provisions all four services (server, client, Postgres, Redis) in one go.

To publish the template (one-time, by the maintainer):

  1. Get the project working using path B or C.
  2. Open the Railway dashboard for that project → SettingsTemplatesPublish Template.
  3. Railway gives you a URL like https://railway.com/template/<id>.
  4. Replace REPLACE_WITH_TEMPLATE_ID in README.md with that URL.

Path B — one-command CLI (bun run deploy:railway)

Requirements:

  • Railway CLI: brew install railway (or bun add -g @railway/cli)
  • A Railway account: railway login (the script does this for you on first run)
  • openssl available (used to generate BETTER_AUTH_SECRET)
bun run deploy:railway

The script (scripts/railway-bootstrap.sh) is idempotent. Re-running it won't duplicate plugins or overwrite an existing auth secret. What it does, in order:

  1. Checks the CLI is installed and you're logged in.
  2. Links to a project (or runs railway init if none is linked).
  3. Adds the Postgres + Redis plugins.
  4. Switches to the server service, sets DATABASE_URL, REDIS_URL, BETTER_AUTH_SECRET, NODE_ENV=production.
  5. Generates a public domain on server, then sets BETTER_AUTH_URL to that domain.
  6. If a client service exists: generates a domain, sets NEXT_PUBLIC_API_URL + NEXT_PUBLIC_APP_NAME, and updates the server's CORS_ORIGINS + APP_URL to match.
  7. Triggers railway up --service server.

Caveat: Railway CLI doesn't create services from a remote GitHub repo — that requires the GitHub integration, which is dashboard-only. The script assumes you've already created two empty services in the project named server and client. If they're missing it tells you what to do and exits cleanly. So the workflow is:

  1. Create the project + the two GitHub-connected services in the dashboard (~30 seconds).
  2. bun run deploy:railway for the rest.

After the script finishes, run the initial migration + seed:

railway run --service server -- bash -c \
  'cd /app/apps/server && bunx prisma migrate deploy && bun run seed'

Path C — manual setup

The full step-by-step is below.

One-time setup

1. Create the Railway project + plugins

  1. From the Railway dashboard, click New ProjectEmpty Project.
  2. Inside the project, click + NewDatabasePostgreSQL. Wait for it to provision; Railway will set a DATABASE_URL variable on the plugin.
  3. Click + NewDatabaseRedis. Same — Railway provides REDIS_URL automatically.

2. Deploy the API server

  1. Click + NewGitHub Repo → pick this repository.

  2. Railway will detect the root railway.toml and build from apps/server/Dockerfile. The first build takes ~3–4 minutes (Bun + Prisma

    • the bundled server).
  3. In the service's Variables tab, reference the plugin URLs:

    DATABASE_URL=${{Postgres.DATABASE_URL}}
    DIRECT_URL=${{Postgres.DATABASE_URL}}
    REDIS_URL=${{Redis.REDIS_URL}}

    Railway resolves the ${{ … }} syntax at runtime — the variables remain linked to the plugin, so rotating the Postgres password updates the server automatically.

    [!IMPORTANT] DIRECT_URL is required even though it looks optional in schema.prisma. Prisma's CLI validates the schema against process.env at parse time and refuses to load the schema if any env variable referenced via env(...) is missing — even when the schema only uses it as a fallback. On Railway's managed Postgres there's no pooler, so DIRECT_URL and DATABASE_URL can point at the same URL. Skip this and prisma migrate deploy will fail with P1012 Environment variable not found: DIRECT_URL.

  4. Add the secrets (use openssl rand -hex 32 for the auth secret):

    BETTER_AUTH_SECRET=<32+ char random hex>
    BETTER_AUTH_URL=https://<this service's railway domain>
    CORS_ORIGINS=https://<the client service's railway domain>
    APP_URL=https://<the client service's railway domain>
    APP_NAME=Your App
  5. Generate a public domain for the service: Settings → Networking → Generate Domain. Railway issues a *.up.railway.app hostname; you can point a custom domain at it later.

3. Deploy the client

  1. Click + NewGitHub Repo → same repository.

  2. Open the new service's Settings and set Config Path to apps/client/railway.toml. Railway re-reads the config and builds from apps/client/Dockerfile.

  3. In Variables, set:

    NEXT_PUBLIC_API_URL=https://<the server service's railway domain>
    NEXT_PUBLIC_APP_NAME=Your App

    [!IMPORTANT] NEXT_PUBLIC_API_URL is the server origin only (no trailing /api/v1). Each consumer (lib/api/client.ts, lib/api/typed-client.ts, the AI streaming page) appends the /api/v1 prefix itself.

    Marketing-only / pre-launch deploys. If you only want to ship the landing page (no API service running yet — useful for a coming-soon page), also set:

    NEXT_PUBLIC_DISABLE_AUTH_CTAS=true

    This hides the nav sign-in button and the per-plan checkout CTAs so the landing renders cleanly without backing infrastructure. Unset it (or set to anything other than true) once the server service is up and you want the full app surface back. Because Next.js inlines NEXT_PUBLIC_* at build time, changing this value triggers a rebuild on the next deploy.

  4. Generate a public domain for the client too. Then go back to the server's variables and update CORS_ORIGINS and APP_URL with the client's actual public URL.

The auto-generated Railway domain (*.up.railway.app) works fine for private testing, but anything user-facing should use your own domain. Two ways:

  • CLI (preferred when it works):

    railway domain --service server api.example.com

    Railway prints the CNAME target you need to set on your DNS provider.

  • Dashboard fallback: if the CLI returns Unauthorized, open the service → Settings → Networking → Add Custom Domain and paste your hostname. Railway shows the same CNAME target in the UI.

    [!NOTE] The CLI sometimes returns Unauthorized for add and domain even when whoami, variables list, and variables set work fine. This happens on personal "My Projects" workspaces and isn't a token-scope issue you can solve by re-logging — it's a workspace-level permission. Use the dashboard for those two operations and the CLI for everything else.

After Railway accepts the custom domain, add a CNAME record in your DNS provider (Cloudflare, Route 53, your registrar) pointing your hostname at the target Railway gave you. Propagation usually takes 5–30 minutes; Railway provisions a TLS cert as soon as the CNAME resolves.

Once https://api.example.com/health returns 200, update BETTER_AUTH_URL on the server to match the new domain (otherwise magic-link / verification emails will mint links pointing at the *.up.railway.app URL). Same for NEXT_PUBLIC_API_URL on the client if the client points at this server.

5. Run the initial migration

Railway runs the server image as soon as it's deployed, but Prisma migrations don't apply automatically (we deliberately don't bundle prisma migrate deploy into the entrypoint — see apps/server/Dockerfile for why). For the very first deploy:

  1. Open the server service's shell tab (or railway run from the CLI).
  2. cd /app/apps/server
    bunx prisma migrate deploy
    bun run seed

After the first run, every push to main ships migrations as part of the image and you can re-run migrate deploy from the shell whenever new migrations land. If you want migrations to apply on every boot, add bunx prisma migrate deploy && in front of CMD in apps/server/Dockerfile.

[!TIP] Running migrations from your local machine. You can also run bunx prisma migrate deploy against the production DB from your laptop without the Railway shell:

# Postgres exposes a public URL alongside the internal one.
PUBLIC_DB=$(railway variables --service Postgres --json | jq -r '.DATABASE_PUBLIC_URL')
cd apps/server
DATABASE_URL="$PUBLIC_DB" DIRECT_URL="$PUBLIC_DB" bunx prisma migrate deploy

The internal *.railway.internal hostname only resolves from inside Railway's network, so railway run against the local CLI fails with Can't reach database server. Use DATABASE_PUBLIC_URL from the Postgres service for ad-hoc maintenance.

6. Run the platform-admin setup wizard

The first time someone visits the deployed client, the middleware redirects every route to /setup. This is a one-time wizard that creates the first platform admin — the user who can promote/demote other admins, suspend orgs, view audit logs, etc.

  1. Open https://<client-domain>/setup
  2. Fill in name, email, and password. You're the platform admin until you promote someone else.
  3. Submit. The wizard calls POST /api/v1/platform/bootstrap which:
    • Creates a User row with platformAdmin: true
    • Disables further bootstrap (subsequent calls return 409)
    • Redirects you to the dashboard

Verify externally:

curl https://<server-domain>/api/v1/platform/bootstrap/status
# → {"needsBootstrap":false}

If you skip this step, every route on the client redirects to /setup including the marketing landing — your visitors won't see the homepage until you complete the wizard. So do this immediately after the first green deploy.

Required vs optional env

The server boots iff these are set on the server service:

VariableSourceValidation
DATABASE_URL${{Postgres.DATABASE_URL}}must be a valid URL
DIRECT_URL${{Postgres.DATABASE_URL}} (same as above on Railway)must be a valid URL — Prisma demands the env even though the schema treats it as a fallback
BETTER_AUTH_SECRETopenssl rand -hex 32minimum 32 characters
BETTER_AUTH_URLthe server's own public Railway URLmust be a valid URL

CORS_ORIGINS defaults to http://localhost:3004; set it to the client's real Railway URL the moment you have one or browser requests will be blocked. REDIS_URL is technically optional (BullMQ + the Redis-backed rate-limit store both no-op when unset) but you'll lose background jobs and shared rate-limiting if you skip Redis entirely.

Everything else (Sentry, OTel, Stripe, MercadoPago, S3, OAuth providers, …) is opt-in — adapters boot to no-op when their env is missing.

[!NOTE] Selling the boilerplate itself via Polar. Setting up Polar.sh as a payment gateway for buying UseDeploy (the source code) is documented separately at Deploy → Polar. It uses the official @polar-sh/better-auth plugin and is loaded conditionally on five POLAR_* env vars — when any is missing, the plugin doesn't load and the boilerplate boots normally.

Why the first deploy fails — and how to recover

Railway's GitHub integration triggers a deploy as soon as you connect the repo, before you've had a chance to add plugins or set env. The build succeeds (Bun, Prisma generate, bundle), the image is pushed, and the container boots — but bootstrap/env.ts throws a Zod validation error, the process exits, and Railway's healthcheck times out:

Attempt #1 failed with service unavailable. Continuing to retry for 19s
Attempt #2 failed with service unavailable. Continuing to retry for 8s
1/1 replicas never became healthy!
Healthcheck failed!

This is normal. Once the plugins are linked and the secrets are set:

  1. Open the server service → Deployments tab.
  2. Click the failed deployment → Redeploy, OR push any commit to trigger a fresh build.
  3. Watch the Logs tab — you should see server listening within ~10–15 seconds. Healthcheck on /health then returns 200.

If a redeploy still fails, open the Logs tab on the failed deployment. The Zod error names the missing/malformed variable, e.g. BETTER_AUTH_SECRET: String must contain at least 32 character(s).

Common ops

# CLI: tail server logs
railway logs --service server

# CLI: shell into the server (run prisma, seed, ad-hoc scripts)
railway shell --service server

# Restart a service after a config change
railway up --service server --detach

Optional services

  • Sentry — set SENTRY_DSN on the server, NEXT_PUBLIC_SENTRY_DSN on the client. Both adapters boot to no-op when unset.
  • OpenTelemetry — set OTEL_EXPORTER_OTLP_ENDPOINT (and optionally OTEL_EXPORTER_OTLP_HEADERS for auth) on the server. OTel must boot before Express loads, which the entrypoint already handles.
  • Stripe — set STRIPE_SECRET_KEY + STRIPE_WEBHOOK_SECRET. The webhook URL on Stripe's side is https://<server-domain>/api/v1/billing/webhooks/stripe.
  • Polar — set POLAR_ACCESS_TOKEN + POLAR_WEBHOOK_SECRET. Webhook URL: https://<server-domain>/api/v1/billing/webhooks/polar.
  • S3 storage — set STORAGE_PROVIDER=s3 plus the S3 keys. Cloudflare R2 works with S3_ENDPOINT=https://<account>.r2.cloudflarestorage.com.

Troubleshooting

Build fails with "Cannot find module '@app/contracts'" — the Dockerfile copies packages/ before running bun install so the workspace symlinks resolve. If you customized the build, make sure packages/ is copied before bun install.

Server boots but /health returns 500 — usually Prisma client wasn't generated. The Dockerfile runs bunx prisma generate in the build stage; if you skipped that stage, run it manually from the shell:

cd /app/apps/server && bunx prisma generate

Client build fails with NEXT_PUBLIC_API_URL is undefined — Next.js inlines NEXT_PUBLIC_* vars at build time. Set them on the client service before the build runs (or trigger a rebuild after setting them).

CORS errors when the client calls the APICORS_ORIGINS on the server must match the client's full origin including protocol (https://app.example.com, not app.example.com). Multiple origins are comma-separated.

bun install is very slow on every deploy — Railway's build cache keys on the Dockerfile contents. Make sure your COPY order in apps/server/Dockerfile puts package.json + bun.lock BEFORE source code so the dependency layer is cached across builds.

Production readiness checklist

  • Custom domain on the client (e.g. app.example.com)
  • Custom domain on the server (e.g. api.example.com)
  • BETTER_AUTH_URL and APP_URL updated to custom domains
  • CORS_ORIGINS includes only the production client origin
  • Sentry / OTel wired up so errors and traces flow somewhere you'll see
  • Stripe / Polar webhooks point at the public server URL with the right secret
  • Healthcheck path verified (/health returns 200)
  • Postgres backup schedule enabled in the Postgres plugin settings
  • At least one non-owner team member added to the Railway project

On this page