Skip to main content
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 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: Deploy on Railway After provisioning, configure both application services to use the production Dockerfile (the template may default to another builder):
  1. Backend service → Settings → Build → Builder: Dockerfile → Dockerfile path: apps/backend/Dockerfile.production.
  2. Consumers service → same: Builder: Dockerfile, Dockerfile path: apps/backend/Dockerfile.production.
These paths are relative to the repository root. See Gotchas for why Dockerfile (not Railpack) and root directory matter. After provisioning, continue with the steps below to verify environment variables and connect your own credentials.

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

Prefer Railway shared variables (project/environment scope) for secrets used by both webapp 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 in apps/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 Node http.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 example compression 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 from apps/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 for SESSION_SECRET, APP_URL, and service-specific env.

Deploying

Migrations and seeds run on every webapp deploy via the Railway pre-deploy command. Treat deploys as data-plane changes:
  1. Use a staging environment that mirrors production (same Dockerfile, shared-variable pattern, Stripe test mode) and promote only after staging migrate/seed succeeds.
  2. Deploy production last.

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 and seed commands run automatically — a bad migration in production is painful to roll back.

Deploy a specific commit

Useful for pinning a rollback or deploying a hotfix from a specific commit.

Fire-and-forget (async) deploy

Returns immediately without waiting for the deployment to complete. Useful in CI pipelines.

The production Dockerfile

Dockerfile.production is a multi-stage build that:
  1. Installs all workspace dependencies with pnpm install --frozen-lockfile (builder stages)
  2. Builds shared and all tooling-* packages via pnpm -r --filter=shared --filter=tooling-* run build (same glob as local migrator/deps-install), then marketing, portal, and backend
  3. Copies build output into a lean runtime image, installs production deps only, and runs as a non-root refract user
  4. Sets NODE_ENV=production and exposes port 3000
  5. 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.
Marketing SEO URLs: Set a Docker build argument APP_URL to your public origin (e.g. https://your-app.up.railway.app or your custom domain without a trailing slash). Use a literal https://… string — Docker build args are not Railway runtime templates; values like ${{…}} that do not expand become invalid and break astro build unless you omit APP_URL (then Astro uses http://localhost:8888 and logs a warning). If APP_URL is missing at build time, canonical / og:url / sitemap links in static HTML stay on localhost even though the site works in the browser. In Railway: service → Settings → Build → Docker Build Arguments. Runtime env APP_URL does not re-write already-built HTML.

Rotating secrets

When rotating a secret (e.g. STRIPE_SECRET_KEY, SENDGRID_API_KEY):
  1. Add the new value to Railway’s environment variables for the affected service.
  2. Remove the old value.
  3. Trigger a redeploy: make deploy ENV=production.
  4. 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

  • 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 /health plus CSP. See Continuous Integration.
  • Builder: Use Dockerfile (BuildKit), not Railpack. Railpack does not run pnpm --filter=backend run build, so apps/backend/dist and pre-deploy scripts such as migrateUp.js never 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 / .), not apps/backend — the Dockerfile runs COPY . /app and expects the full workspace (pnpm-workspace.yaml, all apps/*).
  • 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.
  • 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_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.