Skip to content

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:

  1. First-time provisioning with the Railway CLI (no project exists yet).
  2. 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.

bash
# 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 web

Wire 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

bash
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

bash
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

bash
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

bash
railway service api    && railway up --detach
railway service worker && railway up --detach
railway service web    && railway up --detach

Expose public domains

bash
railway service api && railway domain
railway service web && railway domain

The worker stays internal.

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)

VariableValueNotes
NODE_ENVproduction
LOG_LEVELinfo
RAILWAY_CONFIG_FILErailway.api.toml / railway.worker.toml / railway.web.tomlPer service

api + worker

VariableValue
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_URLhttps://${{web.RAILWAY_PUBLIC_DOMAIN}}/portal/portal suffix required; the code appends /<token> directly
SMTP_HOSTsmtp.resend.com
SMTP_PORT465
SMTP_USERresend
SMTP_PASSResend API key
SMTP_FROMVerified sender address

api only

VariableValue
API_HOST0.0.0.0
API_PORT${{PORT}}
JWT_SECRETopenssl rand -hex 32
MAGIC_LINK_SECRETopenssl rand -hex 32
MFA_SECRET_KEYopenssl rand -hex 32 (64-char hex = 32 bytes)
MFA_ENABLEDtrue
CORS_ORIGINShttps://${{web.RAILWAY_PUBLIC_DOMAIN}}
STAFF_APP_URLhttps://${{web.RAILWAY_PUBLIC_DOMAIN}} — web root; used to build the staff password-setup link. Omit trailing slash
FEATURE_VALIDATION_SYNC_GATEtrue
FEATURE_CYCLE_AUTO_ADVANCEfalse
FEATURE_INTAKE_PER_ACTION_FORMStrue
FEATURE_OCR_FIELD_CONFIRMATIONtrue

worker only

VariableValue
OCR_PROVIDERmockvision+claude
OCR_MAX_PAGES10
ANTHROPIC_API_KEYoperator-supplied
ANTHROPIC_MODELclaude-haiku-4-5-20251001
GOOGLE_VISION_CREDENTIALS_JSONinline service-account JSON blob

web only

VariableValue
NEXT_PUBLIC_API_URLhttps://${{api.RAILWAY_PUBLIC_DOMAIN}}
NEXT_PUBLIC_FEATURE_FIELD_CONFIRMATIONtrue

4. Migrations

The api service's preDeployCommand runs:

pnpm --filter @breezycorp/db exec prisma migrate deploy

This 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:push against the Railway Postgres. db:push bypasses migration history and leaves the database untracked. Only prisma migrate deploy is allowed in production. See CLAUDE.mdDatabase 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

bash
# 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 status

End-to-end smoke test:

  1. Upload a test file through the portal intake flow → confirm the object appears in the Railway Bucket dashboard.
  2. Trigger a magic-link email → confirm delivery in the Resend dashboard.
  3. Check railway logs -s worker for the pg-boss ready line 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.

Internal use only — BreezyCorp