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:
File
What it builds
railway.toml (root)
The Bun + Express API (apps/server/Dockerfile)
apps/client/railway.toml
The 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.
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):
Get the project working using path B or C.
Open the Railway dashboard for that project → Settings → Templates → Publish Template.
Railway gives you a URL like https://railway.com/template/<id>.
Replace REPLACE_WITH_TEMPLATE_ID in README.md with that URL.
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:
Checks the CLI is installed and you're logged in.
Links to a project (or runs railway init if none is linked).
Adds the Postgres + Redis plugins.
Switches to the server service, sets DATABASE_URL,
REDIS_URL, BETTER_AUTH_SECRET, NODE_ENV=production.
Generates a public domain on server, then sets BETTER_AUTH_URL to
that domain.
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.
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:
Create the project + the two GitHub-connected services in the
dashboard (~30 seconds).
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'
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.
Add the secrets (use openssl rand -hex 32 for the auth secret):
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.
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.
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.
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.
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:
Open the server service's shell tab (or railway run from the
CLI).
cd /app/apps/serverbunx prisma migrate deploybun 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/serverDATABASE_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.
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.
Open https://<client-domain>/setup
Fill in name, email, and password. You're the platform admin until
you promote someone else.
Submit. The wizard calls POST /api/v1/platform/bootstrap which:
Creates a User row with platformAdmin: true
Disables further bootstrap (subsequent calls return 409)
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.
The server boots iff these are set on the server service:
Variable
Source
Validation
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_SECRET
openssl rand -hex 32
minimum 32 characters
BETTER_AUTH_URL
the server's own public Railway URL
must 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.
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 19sAttempt #2 failed with service unavailable. Continuing to retry for 8s1/1 replicas never became healthy!Healthcheck failed!
This is normal. Once the plugins are linked and the secrets are set:
Open the server service → Deployments tab.
Click the failed deployment → Redeploy, OR push any commit to
trigger a fresh build.
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).
# CLI: tail server logsrailway logs --service server# CLI: shell into the server (run prisma, seed, ad-hoc scripts)railway shell --service server# Restart a service after a config changerailway up --service server --detach
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.
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 API — CORS_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.