Deploying to Railway
BreezyCorp ships to Railway as three application services + one managed Postgres + one native Storage Bucket, all inside a single Railway project. Deployment is driven by committed config-as-code files — railway.api.toml, railway.worker.toml, railway.web.toml at the repo root — and per-app Dockerfiles under apps/*/Dockerfile.
This doc covers two things:
- First-time provisioning with the Railway CLI (no project exists yet).
- Environment variable matrix that every service needs.
For ongoing changes, commit edits to the railway.*.toml / Dockerfiles and Railway redeploys automatically on push once the repo is linked.
1. Project topology
spade-payroll (Railway project)
├── Postgres (managed plugin)
├── Bucket (native S3-compatible Storage Bucket)
├── api (Dockerfile: apps/api/Dockerfile)
├── worker (Dockerfile: apps/worker/Dockerfile)
└── web (Dockerfile: apps/web/Dockerfile)All three app services build from the same repo root (monorepo context). They are distinguished only by which railway.*.toml config file they point at — set via the RAILWAY_CONFIG_FILE service variable.
The api service owns the migration preDeployCommand (prisma migrate deploy). The worker should always come up against an already-migrated schema.
2. First-time provisioning (CLI)
Run from the repo root on your workstation. Requires a Railway account.
# Install + log in
brew install railway # or: npm i -g @railway/cli
railway login
# Create the project (one-time)
railway init # name it "spade-payroll"
# Managed infrastructure
railway add --database postgres
railway add # pick "Bucket" from the interactive menu; accept name "Bucket"
# Application services (empty shells; each one reads its own railway.*.toml)
railway add --service api
railway add --service worker
railway add --service webWire env vars per service
After the shells exist, set variables per service. ${{...}} are Railway reference variables that resolve at deploy time to values from sibling services. Escape the $ for your shell.
api
railway service api
railway variables \
--set "RAILWAY_CONFIG_FILE=railway.api.toml" \
--set "NODE_ENV=production" \
--set "LOG_LEVEL=info" \
--set "API_HOST=0.0.0.0" \
--set "API_PORT=\${{PORT}}" \
--set "DATABASE_URL=\${{Postgres.DATABASE_URL}}" \
--set "S3_ENDPOINT=\${{Bucket.ENDPOINT}}" \
--set "S3_ACCESS_KEY=\${{Bucket.ACCESS_KEY_ID}}" \
--set "S3_SECRET_KEY=\${{Bucket.SECRET_ACCESS_KEY}}" \
--set "S3_BUCKET=\${{Bucket.BUCKET}}" \
--set "S3_REGION=\${{Bucket.REGION}}" \
--set "JWT_SECRET=$(openssl rand -hex 32)" \
--set "MAGIC_LINK_SECRET=$(openssl rand -hex 32)" \
--set "MFA_SECRET_KEY=$(openssl rand -hex 32)" \
--set "MFA_ENABLED=true" \
--set "CORS_ORIGINS=https://\${{web.RAILWAY_PUBLIC_DOMAIN}}" \
--set "STAFF_APP_URL=https://\${{web.RAILWAY_PUBLIC_DOMAIN}}" \
--set "PORTAL_BASE_URL=https://\${{web.RAILWAY_PUBLIC_DOMAIN}}/portal" \
--set "SMTP_HOST=smtp.resend.com" \
--set "SMTP_PORT=465" \
--set "SMTP_USER=resend" \
--set "SMTP_PASS=<your-resend-api-key>" \
--set "SMTP_FROM=noreply@your-verified-domain.com" \
--set "FEATURE_VALIDATION_SYNC_GATE=true" \
--set "FEATURE_CYCLE_AUTO_ADVANCE=false" \
--set "FEATURE_INTAKE_PER_ACTION_FORMS=true" \
--set "FEATURE_OCR_FIELD_CONFIRMATION=true"worker
railway service worker
railway variables \
--set "RAILWAY_CONFIG_FILE=railway.worker.toml" \
--set "NODE_ENV=production" \
--set "LOG_LEVEL=info" \
--set "DATABASE_URL=\${{Postgres.DATABASE_URL}}" \
--set "S3_ENDPOINT=\${{Bucket.ENDPOINT}}" \
--set "S3_ACCESS_KEY=\${{Bucket.ACCESS_KEY_ID}}" \
--set "S3_SECRET_KEY=\${{Bucket.SECRET_ACCESS_KEY}}" \
--set "S3_BUCKET=\${{Bucket.BUCKET}}" \
--set "S3_REGION=\${{Bucket.REGION}}" \
--set "PORTAL_BASE_URL=https://\${{web.RAILWAY_PUBLIC_DOMAIN}}/portal" \
--set "SMTP_HOST=smtp.resend.com" \
--set "SMTP_PORT=465" \
--set "SMTP_USER=resend" \
--set "SMTP_PASS=<your-resend-api-key>" \
--set "SMTP_FROM=noreply@your-verified-domain.com" \
--set "OCR_PROVIDER=mock" \
--set "OCR_MAX_PAGES=10"Flip OCR_PROVIDER=vision+claude after adding ANTHROPIC_API_KEY, ANTHROPIC_MODEL, and GOOGLE_VISION_CREDENTIALS_JSON (inline service-account JSON blob).
web
railway service web
railway variables \
--set "RAILWAY_CONFIG_FILE=railway.web.toml" \
--set "NODE_ENV=production" \
--set "NEXT_PUBLIC_API_URL=https://\${{api.RAILWAY_PUBLIC_DOMAIN}}" \
--set "NEXT_PUBLIC_FEATURE_FIELD_CONFIRMATION=true"NEXT_PUBLIC_* are compiled into the client bundle at build time. They MUST be set before the first
railway up, otherwise the browser will hit the wrong API origin. If you need to change them later, redeploy.
Deploy
railway service api && railway up --detach
railway service worker && railway up --detach
railway service web && railway up --detachExpose public domains
railway service api && railway domain
railway service web && railway domainThe worker stays internal.
Link to GitHub for auto-deploys
In the Railway dashboard → Project → each app service → Settings → Source → connect to the GitHub repo on branch main. Future pushes trigger rebuilds automatically.
3. Environment variable matrix
Shared (all three app services)
| Variable | Value | Notes |
|---|---|---|
NODE_ENV | production | |
LOG_LEVEL | info | |
RAILWAY_CONFIG_FILE | railway.api.toml / railway.worker.toml / railway.web.toml | Per service |
api + worker
| Variable | Value |
|---|---|
DATABASE_URL | ${{Postgres.DATABASE_URL}} |
S3_ENDPOINT | ${{Bucket.ENDPOINT}} |
S3_ACCESS_KEY | ${{Bucket.ACCESS_KEY_ID}} |
S3_SECRET_KEY | ${{Bucket.SECRET_ACCESS_KEY}} |
S3_BUCKET | ${{Bucket.BUCKET}} |
S3_REGION | ${{Bucket.REGION}} |
PORTAL_BASE_URL | https://${{web.RAILWAY_PUBLIC_DOMAIN}}/portal — /portal suffix required; the code appends /<token> directly |
SMTP_HOST | smtp.resend.com |
SMTP_PORT | 465 |
SMTP_USER | resend |
SMTP_PASS | Resend API key |
SMTP_FROM | Verified sender address |
api only
| Variable | Value |
|---|---|
API_HOST | 0.0.0.0 |
API_PORT | ${{PORT}} |
JWT_SECRET | openssl rand -hex 32 |
MAGIC_LINK_SECRET | openssl rand -hex 32 |
MFA_SECRET_KEY | openssl rand -hex 32 (64-char hex = 32 bytes) |
MFA_ENABLED | true |
CORS_ORIGINS | https://${{web.RAILWAY_PUBLIC_DOMAIN}} |
STAFF_APP_URL | https://${{web.RAILWAY_PUBLIC_DOMAIN}} — web root; used to build the staff password-setup link. Omit trailing slash |
FEATURE_VALIDATION_SYNC_GATE | true |
FEATURE_CYCLE_AUTO_ADVANCE | false |
FEATURE_INTAKE_PER_ACTION_FORMS | true |
FEATURE_OCR_FIELD_CONFIRMATION | true |
worker only
| Variable | Value |
|---|---|
OCR_PROVIDER | mock → vision+claude |
OCR_MAX_PAGES | 10 |
ANTHROPIC_API_KEY | operator-supplied |
ANTHROPIC_MODEL | claude-haiku-4-5-20251001 |
GOOGLE_VISION_CREDENTIALS_JSON | inline service-account JSON blob |
web only
| Variable | Value |
|---|---|
NEXT_PUBLIC_API_URL | https://${{api.RAILWAY_PUBLIC_DOMAIN}} |
NEXT_PUBLIC_FEATURE_FIELD_CONFIRMATION | true |
4. Migrations
The api service's preDeployCommand runs:
pnpm --filter @breezycorp/db exec prisma migrate deployThis is idempotent — safe to run on every deploy, skips migrations already applied. It bypasses the repo's dotenv-cli wrapper because Railway injects env vars directly and there is no .env file in the container.
Never run
pnpm db:pushagainst the Railway Postgres.db:pushbypasses migration history and leaves the database untracked. Onlyprisma migrate deployis allowed in production. SeeCLAUDE.md→ Database Migration Workflow for the full policy.
Rollback for destructive migrations (DROP, ALTER TYPE) uses the hand-written rollback.sql stored next to each migration — invoked manually via the runbook in docs/runbooks/.
5. Verification after deploy
# Health
curl https://<api-domain>/health/live # → 200
curl https://<api-domain>/docs # → Swagger UI renders
curl https://<web-domain>/ # → Next.js renders
# Logs
railway logs -s api
railway logs -s worker
railway logs -s web
# Migration status
railway run -s api pnpm --filter @breezycorp/db exec prisma migrate statusEnd-to-end smoke test:
- Upload a test file through the portal intake flow → confirm the object appears in the Railway Bucket dashboard.
- Trigger a magic-link email → confirm delivery in the Resend dashboard.
- Check
railway logs -s workerfor the pg-bossreadyline and any job handler output.
6. Rollback
Each Railway service keeps prior deploys. From the dashboard → service → Deployments → pick a previous build → Redeploy. Schema-destructive migrations also need the hand-written rollback.sql from the corresponding packages/db/prisma/migrations/* folder — applied manually.