Railway is the default deployment target for Refract. The stack deploys as two services — webapp and consumer — both built from Dockerfile.production and configured from environment variables set in your Railway project.
How it works
The production build compiles all workspace packages and bundles the frontend as static files served by the Express backend on port 3000. Two Railway services handle different responsibilities:
| Service | Role | Entry point |
|---|
webapp | API, GraphQL, serves frontend | node apps/backend/dist/index.js |
consumer | Background job consumer | node apps/backend/dist/consumer.js |
Both services run from the same Docker image. The webapp service runs migrations and seeds before starting via a Railway pre-deploy command.
Setup
1. Create a Railway project
Create a new project in Railway and note the project ID. Add two services — name them webapp and consumer. You’ll need the service IDs for each.
2. Add environment variables
In each Railway service, set all the variables your production config reads. At minimum:
# Application
NODE_ENV=production
BACKEND_URL=https://your-domain.up.railway.app
FRONTEND_URL=https://your-domain.up.railway.app
PORT=3000
# Database — use Railway's Postgres plugin or an external instance
PGHOST=...
PGPORT=5432
PGUSER=...
PGPASSWORD=...
PGDATABASE=...
# Redis — use Railway's Redis plugin or an external instance
REDIS_URL=redis://...
# Stripe
STRIPE_API_VERSION=2026-01-28.clover
STRIPE_SECRET_KEY=sk_live_...
STRIPE_PUBLISHABLE_KEY=pk_live_...
STRIPE_WEBHOOK_SECRET=whsec_...
# SendGrid (if using Twilio SendGrid mailer)
SENDGRID_API_KEY=SG....
Set the Railway deployment environment variables in your local .env.development (or your CI environment):
RAILWAY_TOKEN=your_railway_api_token
RAILWAY_PROJECT_ID=your_project_id
RAILWAY_ENVIRONMENT=production # or staging
RAILWAY_WEBAPP_SERVICE_ID=your_webapp_id
RAILWAY_CONSUMER_SERVICE_ID=your_consumer_id
In apps/backend/src/configuration/production.ts, set the deployment block with your service names and IDs:
deployment: {
strategy: DeploymentStrategy.Railway,
apiToken: process.env.RAILWAY_TOKEN,
projectId: process.env.RAILWAY_PROJECT_ID,
environmentId: process.env.RAILWAY_ENVIRONMENT,
services: [
{
name: 'webapp',
serviceId: process.env.RAILWAY_WEBAPP_SERVICE_ID,
dockerfilePath: 'apps/backend/Dockerfile.production',
healthcheckPath: '/health',
preDeployCommand: 'node apps/backend/dist/tools/rds/sequelize/migrateUp.js && node apps/backend/dist/tools/rds/sequelize/seedUp.js',
},
{
name: 'consumer',
serviceId: process.env.RAILWAY_CONSUMER_SERVICE_ID,
dockerfilePath: 'apps/backend/Dockerfile.production',
startCommand: 'node apps/backend/dist/consumer.js',
},
],
},
Deploying
Deploy to staging
Deploy to production
Both commands run make deploy ENV=<environment> under the hood, which builds the deployment package inside the running backend container and triggers a Railway redeploy via the deploy script at apps/backend/scripts/deploy.ts.
Always deploy to staging and verify before deploying to production. The pre-deploy migration command runs automatically — a bad migration in production is painful to roll back.
Deploy a specific commit
make deploy ENV=production SHA=<commit-hash>
Useful for pinning a rollback or deploying a hotfix from a specific commit.
Fire-and-forget (async) deploy
make deploy ENV=staging ASYNC=true
Returns immediately without waiting for the deployment to complete. Useful in CI pipelines.
The production Dockerfile
Dockerfile.production is a multi-stage build that:
- Installs all workspace dependencies with
pnpm install --frozen-lockfile
- Builds
shared, all tooling-* packages, frontend, and backend in sequence
- Sets
NODE_ENV=production and exposes port 3000
- Starts with
node apps/backend/dist/index.js
The frontend’s dist/ output is copied into apps/frontend/dist at build time. The Express backend serves it as static files in production — no separate frontend hosting needed.
Rotating secrets
When rotating a secret (e.g. STRIPE_SECRET_KEY, SENDGRID_API_KEY):
- Add the new value to Railway’s environment variables for the affected service.
- Remove the old value.
- Trigger a redeploy:
make deploy ENV=production.
- Verify the service is healthy via Railway’s logs before removing the old credential from Stripe/SendGrid.
If a credential was ever committed to version control, rotate it immediately — even if it was only in a branch. Railway env vars are the source of truth; .env.development is local only and gitignored.
Gotchas
PORT must be set in Railway — Railway dynamically assigns the external port and proxies to it. The backend reads PORT from the environment to bind Express.
- The
consumer service has no health check path. Railway will mark it healthy as soon as the process starts. If the consumer crashes immediately, check the Railway logs for startup errors.
- Migrations run as part of the
webapp pre-deploy command. If a migration fails, the deployment is aborted and the previous version keeps running.
RAILWAY_ENVIRONMENT must match an environment ID in your Railway project, not just a string like "production". Get the value from the Railway dashboard.
What’s next?
- Updating Refract — pull upstream improvements into your project after deploying.
- Configuration — how
production.ts and environment variables fit together.
- Support — if a deployment fails and you’re stuck.