Skip to content

Railway

Your whole production (Postgres, Redis, api, web, site) is described in .railway/railway.ts. You preview what it would change and apply it, instead of clicking it together in the dashboard.

  • A Railway account, and Railway’s GitHub app installed on the account or organisation that owns your repository, with access to it: github.com/apps/railway-app/installations/new. Without it the services are created but never build. Railway builds from GitHub, so push your code first.
  • An email provider. Without one nobody can verify their address, so nobody can sign in. See Email provider.
  • Node 22 or newer. Railway’s CLI runs .railway/railway.ts with it.
  1. Create your production settings file:

    Terminal window
    bun run deploy:init

    It writes .env.railway with a new AUTH_SECRET. The file is never committed.

  2. Fill in .env.railway. The minimum:

    Terminal window
    RAILWAY_PROJECT=my-saas
    ADMIN_EMAILS=you@example.com
    SMTP_HOST=smtp.mailgun.org
    SMTP_PORT=587
    SMTP_USER=...
    SMTP_PASSWORD=...
    MAIL_FROM=My SaaS <hello@example.com>

    Billing, AI and the rest can wait. RAILWAY_REPO can stay empty: it defaults to the repository this folder was cloned from.

  3. Sign in and create the project, with the name you wrote in RAILWAY_PROJECT:

    Terminal window
    bun run railway login
    bun run railway init
  4. Preview, then apply. deploy:plan changes nothing; deploy:apply checks .env.railway against the API’s rules (Before you deploy), then shows the plan again and asks first:

    Terminal window
    bun run deploy:plan
    bun run deploy:apply
  5. Give the app and the site an address, before anything deploys. The API builds its links from the web app’s address, and refuses to start without one:

    Terminal window
    bun run railway domain --service web --port 8080
    bun run railway domain --service site --port 8080
  6. Connect each service to your repository. Applying records which repository to build, but does not build it or deploy on push; connecting does both, starting with your latest commit:

    Terminal window
    bun run railway service source connect --repo <owner/name> --branch main --service api
    bun run railway service source connect --repo <owner/name> --branch main --service web
    bun run railway service source connect --repo <owner/name> --branch main --service site

    Follow a service with bun run railway logs --service api. The API’s first lines are Migrations applied..

  7. Check it the way a visitor reaches it:

    Terminal window
    bun run deploy:check https://<web address> --site https://<site address> --email you@example.com

bun run railway is Railway’s CLI pinned to the version the kit is tested with. Use it instead of a railway you have installed.

It is the only file .railway/railway.ts reads; your shell’s environment is ignored.

Variable Effect
RAILWAY_PROJECT The project it may change. See the guard below
RAILWAY_REPO, RAILWAY_BRANCH What Railway builds. Default: this repository, main
WEB_DOMAIN, SITE_DOMAIN Your own domains. Empty uses Railway’s addresses
RAILWAY_WORKER true runs jobs in a separate worker service
Everything else Passed to the API. See Environment variables

A value you leave empty is left as it is on Railway, so a secret can live only in Railway’s dashboard. DATABASE_URL, WEB_URL, PORT, API_URL and the site’s addresses are worked out for you.

The CLI can take its project link from a folder above this one, which may be another app’s production. So the file stops unless the linked project is named what RAILWAY_PROJECT says:

This folder is linked to the Railway project "other-app", and .env.railway says
RAILWAY_PROJECT=my-saas. Nothing was changed.

Fix it with bun run railway link. Read every plan before you apply it anyway.

A push to your branch rebuilds only the services whose files changed: a change in apps/web leaves the API running. Then, for each one:

  1. For the API, migrations run first. If they fail, the release stops and the version that is running keeps serving.
  2. The new version starts, and Railway asks it whether it is healthy: /api/health for the API, /healthz for the web app and the site. See Health checks.
  3. Only once it answers does it take over. If it never does, the old version stays.

A service that exits is restarted, up to 5 times in a row. So a migration must work with the code that is still running: add a column in one release, stop using the old one in the next.

Do it in this order. Sign-in only accepts requests from the app’s own address, so if the app moved to a domain whose DNS is not ready, nobody could sign in until it was.

  1. Attach the domain. It prints the DNS records to add:

    Terminal window
    bun run railway domain app.example.com --service web --port 8080
  2. Add those records (a CNAME and a TXT) where your domain’s DNS is managed, and wait until https://app.example.com loads.

  3. Set WEB_DOMAIN=app.example.com in .env.railway (no https://), then deploy:plan and deploy:apply. The API’s WEB_URL and the site’s links follow it. From then on the Railway address still shows the app, but sign-in refuses it.

  4. Give Stripe or Polar (the webhook endpoint), Google and GitHub the new address, if you use them.

The same goes for the site with SITE_DOMAIN and --service site.

Set RAILWAY_WORKER=true and apply. A worker service starts from the API image with bun src/worker.ts, and the API gets JOBS_WORKER=false.

  • Release code: push to the deployed branch.
  • Change a variable, domain or service: edit .env.railway or .railway/railway.ts, then plan and apply. A new API variable goes in apiSettings in packages/deploy/src/production.ts, in YOURS in .railway/railway.ts, and in .env.railway.example and .env.aws.example. A test fails until they agree.

Don’t change settings in Railway’s dashboard: according to Railway’s docs, applying the file removes what it doesn’t describe. The plan marks removals.

Errors: see Troubleshooting.