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
Prefer Railway shared variables (project/environment scope) for secrets used by bothwebapp and consumer — SESSION_SECRET, Stripe keys, Postgres, Redis, mailer credentials — instead of copying the same values into each service. Service-specific overrides (e.g. consumer startCommand) stay on the service.
At minimum, ensure these are set (shared or per-service):
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 sets backend.trustProxy: 1 (hop count only — booleans are rejected). Express then derives
req.ip from the rightmost trusted X-Forwarded-For hop (Railway’s edge). Using true would
trust every hop and let clients spoof the leftmost value, which breaks IP-based rate limits.
Rate limiting keys on req.ip (and per-account email buckets for sign-in / password reset).
If you add another reverse proxy (e.g. Cloudflare) in front of Railway, raise the hop count
accordingly. 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 a baseline Content-Security-Policy (buildBaselineContentSecurityPolicy in apps/backend/src/constants/securityHeaders.ts) and crossOriginEmbedderPolicy: false.
Mode: set CSP_MODE=enforce (default when unset) or CSP_MODE=report-only. Invalid values fall back to enforce with a warning. Use report-only temporarily as a safety switch when diagnosing a production CSP regression; domain allowlists and inline-script hashes stay in code.
Allowlists: 'self', Stripe (js.stripe.com, *.js.stripe.com, api.stripe.com, hooks.stripe.com), and Google Analytics / Tag Manager (googletagmanager.com, google-analytics.com, and related connect hosts). Marketing theme / GA consent / Astro island inline scripts are allowed via SHA-256 hashes written at marketing build time to apps/marketing/dist/client/csp-inline-script-hashes.json (no 'unsafe-inline' for scripts). If that file is missing, unreadable, or empty when marketing.staticPath is set, webapp startup fails so a broken enforce-mode CSP is never served. MCP OAuth consent paths strip form-action so browser redirects to loopback or custom-scheme clients succeed. Tighten further at a CDN if you terminate there. Apollo Server also enforces GraphQL query depth and complexity limits from the same constants module.
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; set backend.trustProxy to the real proxy hop count (not true) when client IP and TLS termination come from forwarded headers.
📖 See also: Configuration forSESSION_SECRET,APP_URL, and service-specific env.
Deploying
Migrations and seeds run on everywebapp deploy via the Railway pre-deploy command. Treat deploys as data-plane changes:
- Use a staging environment that mirrors production (same Dockerfile, shared-variable pattern, Stripe test mode) and promote only after staging migrate/seed succeeds.
- Deploy production last.
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(builder stages) - Builds
sharedand alltooling-*packages viapnpm -r --filter=shared --filter=tooling-* run build(same glob as local migrator/deps-install), thenmarketing,portal, andbackend - Copies build output into a lean runtime image, installs production deps only, and runs as a non-root
refractuser - 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
- CI production smoke: Ready PRs run
make verify-production-smoke(manual Run workflow starts only this job). It builds this Dockerfile, applies the same pre-deploy migrate+seed inside the image, fails if compiled migrations are missing or expected tables are empty, boots with throwaway Postgres 18.6 / Redis 8.2.2, and checks/healthplus CSP. See Continuous Integration. - 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.
