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 workspace packages, builds marketing and portal, and serves both from the Express webapp on port 3000. Two Railway services handle different responsibilities:
Both services run from the same Docker image. The
webapp service runs migrations and seeds before starting via a Railway pre-deploy command.
Setup
Quick start with the Refract Railway template
Use the one-click template to create a project with the baseline Refract services pre-configured:- Backend service → Settings → Build → Builder: Dockerfile → Dockerfile path:
apps/backend/Dockerfile.production. - Consumers service → same: Builder: Dockerfile, Dockerfile path:
apps/backend/Dockerfile.production.
1. Create a Railway project
Create a new project in Railway and note the project ID. Add two services — name themwebapp 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:3. Configure Railway credentials locally
Set the Railway deployment environment variables in your local.env.development (or your CI environment):
4. Configure production.ts
In apps/backend/src/configuration/production.ts, set the deployment block with your service names and IDs:
production.ts also sets backend.trustProxy: true so Express trusts Railway’s proxy headers
(X-Forwarded-For, etc.) — that keeps client IP–based rate limits accurate behind the platform
edge. backend.publicSubscribe.enabled toggles the public mailing-list subscribe GraphQL
path; see Configuration for semantics and local vs production
defaults.Production web server hardening
Express tuning for security and predictable behavior behind Railway’s proxy lives inapps/backend/src/index.ts, with shared limits and timeouts in apps/backend/src/constants/http.ts.
Security headers (helmet)
The app uses helmet with contentSecurityPolicy: false and crossOriginEmbedderPolicy: false. You still get useful defaults (for example X-Content-Type-Options, X-DNS-Prefetch-Control, and frame controls). Content-Security-Policy is not enabled by default here because the same Node process serves static HTML (marketing + portal builds) alongside the API; a strict default CSP often breaks hashed bundles or third-party scripts. If you put a CDN or dedicated reverse proxy in front of APP_URL, that is the right place to add a strict CSP and tune it without fighting Express static behavior.
JSON body size
express.json() uses a 1mb limit (JSON_BODY_LIMIT in apps/backend/src/constants/http.ts) for GraphQL and other JSON APIs. Stripe’s endpoint is separate (see below). If a legitimate client needs larger payloads, raise the constant and re-verify uploads or batched operations.
Stripe webhook (raw body, no req.rawBody)
Stripe signature verification needs the exact raw bytes of the JSON body. The app mounts POST on STRIPE_WEBHOOK_PATH (/api/stripe/webhook) before express.json() with express.raw({ type: 'application/json' }), so req.body is a Buffer for Stripe.webhooks.constructEvent. There is no global req.rawBody on Express.Request.
HTTP server timeouts
The Nodehttp.Server sets keepAliveTimeout and headersTimeout from apps/backend/src/constants/http.ts so connection reuse lines up with common reverse-proxy idle timeouts (often around 60s). Adjust these if your host documents different expectations.
Compression and Railway
Railway’s platform edge does not automatically compress responses for your service the way some CDNs do for static sites. In practice you either compress inside Node (for examplecompression middleware) or terminate behind a CDN / proxy that honors Accept-Encoding. If you use both app-level compression and a CDN that compresses, configure carefully so you do not double-encode bodies or fight over Vary headers—pick one intentional layer.
Static assets and caching
Production browser assets are served fromapps/backend/src/routers/browser-static/ (buildBrowserStaticRouter): prerendered marketing from marketing/dist/client (static middleware before Astro SSR), blog SSR from marketing/dist/server/entry.mjs with loopback GraphQL (marketing.graphqlUrl → 127.0.0.1:$PORT/graphql), then portal dist, then SPA fallback. Vite emits hashed files under assets/; those filenames are safe to treat as immutable with long Cache-Control max-age at whatever layer serves them (Express express.static maxAge, CDN, or both). index.html and SPA fallbacks should stay short-cache or no-cache so new deploys win immediately. A CDN in front of APP_URL is the usual way to add global caching, compression, and WAF; keep backend.trustProxy: true in production config when client IP and TLS termination come from forwarded headers.
📖 See also: Configuration forSESSION_SECRET,APP_URL, and service-specific env.
Deploying
Deploy to staging
Deploy to production
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.
Deploy a specific commit
Fire-and-forget (async) deploy
The production Dockerfile
Dockerfile.production is a multi-stage build that:
- Installs all workspace dependencies with
pnpm install --frozen-lockfile - Builds
sharedand alltooling-*packages viapnpm -r --filter=shared --filter=tooling-* run build(same glob as local migrator/deps-install), thenmarketing,portal, andbackend - Sets
NODE_ENV=productionand exposes port3000 - Starts with
node apps/backend/dist/index.js
apps/marketing/dist and apps/portal/dist are produced at build time. Express serves marketing HTML for public routes, then portal assets and SPA fallback — no separate static hosting service.
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.
Gotchas
- Builder: Use Dockerfile (BuildKit), not Railpack. Railpack does not run
pnpm --filter=backend run build, soapps/backend/distand pre-deploy scripts such asmigrateUp.jsnever exist. Dockerfile path must be relative to the repository root:apps/backend/Dockerfile.production(no leading/). Root directory for the service should be the repo root (leave empty /.), notapps/backend— the Dockerfile runsCOPY . /appand expects the full workspace (pnpm-workspace.yaml, allapps/*). PORTmust be set in Railway — Railway dynamically assigns the external port and proxies to it. The backend readsPORTfrom the environment to bind Express.- The
consumerservice 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
webapppre-deploy command. If a migration fails, the deployment is aborted and the previous version keeps running. - One-off operational scripts (catalog backfills, invoice reconciliation, etc.) are separate from pre-deploy migrate/seed. Run compiled JS on the built image, e.g.
node apps/backend/dist/scripts/migrateLegacyProductsToV1.js --help. See Operational Scripts. RAILWAY_ENVIRONMENTmust 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.tsand environment variables fit together. - Support — if a deployment fails and you’re stuck.
