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

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 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 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 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; keep backend.trustProxy: true in production config when client IP and TLS termination come from forwarded headers.
📖 See also: Configuration for SESSION_SECRET, APP_URL, and service-specific env.

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

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
  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. Sets NODE_ENV=production and exposes port 3000
  4. 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

  • 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.