Toast
Contributor

Railway Setup Guide

Railway Setup Guide

This document describes how to set up the Railway infrastructure for this project from scratch.

Prerequisites

  • Railway CLI installed
  • Railway account with billing enabled
  • GitHub repository connected to Railway

Infrastructure Overview

The project requires four services in Railway:

  1. API - The Hono API application (deployed from GitHub via Dockerfile)
  2. admin - The React admin panel (deployed from GitHub via Dockerfile + nginx)
  3. docs - The Fumadocs documentation site (deployed from GitHub via Dockerfile + Next.js standalone)
  4. Postgres - PostgreSQL database service

How Railway Environments Work

Understanding Railway's environment model is essential for managing secrets and deployments.

Environments

Railway creates environments that contain isolated copies of all services:

  • staging - The base environment, deployed from main branch
  • PR previews - Ephemeral environments created automatically for each pull request

PR environments are created as copies of staging. When a PR is opened, Railway clones the staging environment (including all variables) and deploys the PR branch to it.

Environment Variables

Variables in Railway are scoped to environment + service. There's no global "project-level" variable - each service in each environment has its own set.

Key insight: PR environments inherit variables from staging at creation time. If you add a new variable to staging after a PR environment was created, that PR environment won't have it.

Railway CLI Basics

The CLI operates on the currently linked project/environment/service:

# Check what you're linked to
railway status

# Link to the project (run from repo root)
railway link

# Switch environment
railway link -e staging
railway link -e pr-123

# Switch service (within current environment)
railway link -s API
railway link -s admin

# View variables for current service
railway variables

# Set variables on current service
railway variables --set 'KEY=value' --set 'OTHER_KEY=value'

# View logs
railway logs

Testing a PR That Needs New Environment Variables

When testing a PR that introduces new environment variables (e.g., a new integration or driver):

  1. Find the PR preview environment name: Toast-pr-{number} (e.g., Toast-pr-453)

  2. Set variables on the PR's API service:

    railway link -p 11ca9fbb-df9c-4775-808c-593cbfbeb515 -e "Toast-pr-453" -s API
    railway variables --set 'NEW_VAR=value' --set 'OTHER_VAR=value'
  3. Get the PR preview URLs:

    railway domain  # Shows API URL
    railway link -s admin && railway domain  # Shows admin URL
  4. Test the feature at https://admin-toast-pr-{number}.up.railway.app

The PR environment auto-redeploys when variables change. If not, trigger manually from the Railway dashboard.

Important: PR environments inherit from staging at creation time. If the PR was created before the variables existed on staging, you must add them to the PR environment manually.

Adding a New Secret

When you add a dependency that requires a new environment variable:

  1. Add to .env.example - Document what the variable is for
  2. Set on staging services - Use the CLI to set it on each service that needs it
  3. Update GitHub secrets - If CI needs it, add to repository secrets
  4. Update existing PR environments - Use the CLI to set the variable on each PR environment

Example workflow for staging:

# 1. Link to staging
railway link -e staging

# 2. Set on the service that needs it
railway link -s admin
railway variables --set 'NEW_API_KEY=xxx'

# 3. If another service needs it too
railway link -s API
railway variables --set 'NEW_API_KEY=xxx'

Updating existing PR environments:

PR environments are named Toast-pr-{number}. You must update each one manually - deleting and recreating them is unreliable.

# Get list of PR environment names from GitHub deployments
gh api repos/TryGhost/Toast/deployments --jq '.[].environment' | grep "pr-" | sort -u

# Update each PR environment (repeat for each PR number)
railway link -p 11ca9fbb-df9c-4775-808c-593cbfbeb515 -e "Toast-pr-123" -s admin
railway variables --set 'NEW_API_KEY=xxx'

railway link -p 11ca9fbb-df9c-4775-808c-593cbfbeb515 -e "Toast-pr-123" -s API
railway variables --set 'NEW_API_KEY=xxx'

For many PRs, script it:

for pr in 360 363 364 365 366; do
  railway link -p PROJECT_ID -e "Toast-pr-$pr" -s admin
  railway variables --set 'NEW_API_KEY=xxx'
  railway link -p PROJECT_ID -e "Toast-pr-$pr" -s API
  railway variables --set 'NEW_API_KEY=xxx'
done

Setup Steps

1. Install and authenticate Railway CLI

# Install (macOS)
brew install railway

# Login
railway login

2. Create a new Railway project

# Create project and link to current directory
railway init

Or create via the Railway dashboard.

3. Connect GitHub repository

In the Railway dashboard:

  1. Go to your project
  2. Click "New Service" > "GitHub Repo"
  3. Select the Toast repository
  4. Railway will auto-detect the railway.json config

4. Add PostgreSQL database

# Link to your project first
railway link

# Deploy PostgreSQL template
railway deploy-template postgres

Or via dashboard: New Service > Database > PostgreSQL

5. Configure environment variables

The app needs DATABASE_URL to connect to PostgreSQL. Set it to reference the Postgres service:

railway variables --service Toast --set 'DATABASE_URL=${{Postgres.DATABASE_URL}}'

The ${{Postgres.DATABASE_URL}} syntax is Railway's variable interpolation - it automatically resolves to the internal database URL.

6. Generate a public domain

railway domain --service Toast

Or via dashboard: Service Settings > Networking > Generate Domain

7. Verify API deployment

# Check health endpoint
curl https://your-app.up.railway.app/healthz

# Check database connectivity
curl https://your-app.up.railway.app/healthz/ready

Seed data fixtures

Seed data lives in packages/db/fixtures/content.json and uses TipTap JSON blocks (doc → content → paragraph/heading/list/image). The seed script expects 12 items covering drafts, published posts, empty bodies, and rich content examples. Update packages/db/src/seed.ts if the fixture count changes.

Environment seeding behavior

EnvironmentMigrationsSeedingContent Persistence
ProductionYesNoPersistent
StagingYesYesEphemeral - reset on every deploy
PR PreviewYesYesEphemeral - reset on every deploy

Staging content is ephemeral: The seed script deletes and recreates all content on every staging deployment. This ensures staging always has a known state for testing, but any content created or modified in staging will be lost on the next deploy. Do not use staging to preview content destined for production.

Expected responses:

{"status":"ok"}
{"status":"ok","database":"connected"}

8. Add the admin service

The admin panel is a separate service deployed from the same repository.

# Add a new service from the GitHub repo
railway add --service admin --repo TryGhost/Toast

9. Configure admin service settings

In the Railway dashboard, go to the admin service settings:

  1. Config File Path: Set to apps/admin/railway.json
    • This is critical - without this, Railway uses the root railway.json (the API config)
    • The path is relative to the repo root, without a leading slash
  2. Dockerfile Path: Set to apps/admin/Dockerfile.admin
    • Do not include a leading slash; Railway treats /apps/... as absolute and fails
  3. The railway.json in apps/admin/ configures:
    • Dockerfile builder pointing to apps/admin/Dockerfile.admin
    • Watch patterns for apps/admin/**, packages/ui/**, and packages/config/**
    • Health check on / (returns index.html)

The admin Dockerfile:

  • Builds the Vite static site in a monorepo-aware multi-stage build
  • Serves via nginx (~7MB image vs ~150MB for Node.js)
  • Handles SPA routing (all routes serve index.html)
  • Listens on the Railway PORT env via nginx templates
  • Injects VITE_API_URL into config.js at runtime (served from /config.js)

Fallback behavior: The root Dockerfile also includes admin build outputs and docker-entrypoint.sh starts the admin panel when RAILWAY_SERVICE_NAME=admin. This protects against Railway falling back to the root Dockerfile even when a service-specific Dockerfile is configured.

10. Set admin environment variables

# Set the API URL for the admin panel (injected at runtime)
railway variables --service admin --set 'VITE_API_URL=https://${{API.RAILWAY_PUBLIC_DOMAIN}}'

The ${{API.RAILWAY_PUBLIC_DOMAIN}} syntax references the API service's public domain within the same environment. This ensures PR environments point to their own API, not staging.

The admin reads runtime config from /config.js and falls back to the VITE_API_URL build-time value during local development.

11. Generate domain for admin service

railway domain --service admin

12. Add the docs service

The documentation site is a separate service deployed from the same repository.

# Add a new service from the GitHub repo
railway add --service docs --repo TryGhost/Toast

13. Configure docs service settings

In the Railway dashboard, go to the docs service settings:

  1. Config File Path: Set to apps/docs/railway.json
    • This is critical - without this, Railway uses the root railway.json (the API config)
    • The path is relative to the repo root, without a leading slash
  2. Dockerfile Path: Set to apps/docs/Dockerfile.docs
    • Do not include a leading slash; Railway treats /apps/... as absolute and fails
  3. The railway.json in apps/docs/ configures:
    • Dockerfile builder pointing to apps/docs/Dockerfile.docs
    • Watch patterns for apps/docs/**, docs/**, and pnpm-lock.yaml
    • Health check on / (returns the docs homepage)

The docs Dockerfile:

  • Builds Next.js in standalone mode for a minimal runtime image
  • Resolves symlinks from content/code/ to the repo-root docs/ directory
  • Serves via Node.js on port 3000 (Railway sets PORT automatically)
  • Requires TIPTAP_PRO_TOKEN at build time (for private npm registry access)
  • No runtime environment variables required (content is statically built)

14. Generate domain for docs service

railway domain --service docs

15. Configure Admin URL on API service

The API needs to know the admin panel URL for CORS and auth trusted origins. Use Railway's variable interpolation so PR environments automatically get the correct URL:

railway variables --service API --set 'ADMIN_URL=https://${{admin.RAILWAY_PUBLIC_DOMAIN}}'

16. Custom domains (staging)

Staging uses custom domains on toast.place instead of Railway's default up.railway.app domains:

ServiceCustom DomainRailway CNAME Target
APIapi.staging.toast.placeCopy from Railway dashboard
Adminadmin.staging.toast.placeCopy from Railway dashboard
Docsdocs.staging.toast.placeCopy from Railway dashboard

Use the exact CNAME target shown by Railway for your own service (Networking -> Public Networking). Do not copy *.up.railway.app values from this guide.

DNS is managed in the dnscontrol repo (src/toast.place.js). Each custom domain requires:

  1. A CNAME record pointing to the Railway-provided target
  2. A TXT record (_railway-verify.{name}) for domain ownership verification

Railway issues SSL certificates automatically once the DNS records are in place. Cloudflare proxy must be off (CF_PROXY_OFF) for staging subdomains because Cloudflare's Universal SSL only covers *.toast.place, not *.staging.toast.place (two-level subdomains require Advanced Certificate Manager).

Staging environment variables use the custom domains directly (not Railway interpolation):

# API service
ADMIN_URL=https://admin.staging.toast.place
BETTER_AUTH_URL=https://api.staging.toast.place
CORS_ORIGIN=https://admin.staging.toast.place
COOKIE_DOMAIN=.staging.toast.place
CROSS_SITE_COOKIES=false

# Admin service
VITE_API_URL=https://api.staging.toast.place

17. PR preview variable behavior (current state)

Railway's up.railway.app domain is on the Public Suffix List, which means browsers treat each subdomain as a separate "site". This breaks session cookies because:

  1. Login sets a cookie on the API domain with SameSite=Lax (default)
  2. Requests from admin are treated as cross-site
  3. SameSite=Lax cookies aren't sent on cross-site fetch requests
  4. User appears logged out after successful login

Staging does not have this problem because all services share the .staging.toast.place parent domain, so cookies work normally with SameSite=Lax.

Current Railway project reality: PR preview environments are copied from staging with the same app-level variables. In practice, that means API/admin vars like ADMIN_URL, BETTER_AUTH_URL, CORS_ORIGIN, and VITE_API_URL still point to *.staging.toast.place, and CROSS_SITE_COOKIES remains false.

This keeps previews attached to staging endpoints by default instead of becoming fully isolated per-PR environments.

If you need a PR environment to run end-to-end on its own *.up.railway.app domains, override variables on that PR environment manually:

# API service (in Toast-pr-<number>)
ADMIN_URL=https://admin-toast-pr-<number>.up.railway.app
BETTER_AUTH_URL=https://api-toast-pr-<number>.up.railway.app
CORS_ORIGIN=https://admin-toast-pr-<number>.up.railway.app
CROSS_SITE_COOKIES=true

# Admin service (in Toast-pr-<number>)
VITE_API_URL=https://api-toast-pr-<number>.up.railway.app

18. Verify admin deployment

Visit your admin domain in a browser. The welcome page should:

  • Display "Welcome to Toast Admin"
  • Show API health status (fetched via CORS)
  • Display the API URL being used

19. Configure additional secrets

The app requires various API keys and secrets. Follow the "Adding a New Secret" workflow above to configure these on staging. PR environments will inherit them when created.

Required Environment Variables

API Service:

VariablePurposeBuild/Runtime
DATABASE_URLPostgreSQL connection stringRuntime
BETTER_AUTH_SECRETSession signing keyRuntime
TIPTAP_PRO_TOKENNPM registry auth for TipTap ProBuild
TIPTAP_COLLAB_SECRETJWT signing for collaboration tokensRuntime
TIPTAP_AI_SECRETJWT signing for AI tokensRuntime
VITE_TIPTAP_COLLAB_APP_IDReturned in collaboration token responseRuntime
VITE_TIPTAP_AI_APP_IDReturned in AI token responseRuntime
ADMIN_URLAdmin panel URL for CORSRuntime
BETTER_AUTH_URLAPI base URL used by Better AuthRuntime
COOKIE_DOMAINOptional legacy cookie domain variable (currently unused by API code)Runtime
CROSS_SITE_COOKIESEnable SameSite=None for cross-site Railway previewsRuntime
EMAIL_DRIVEREmail provider (e.g., toast-driver-email-mailgun)Runtime
EMAIL_FROMDefault sender address for transactional emailsRuntime
MAILGUN_API_KEYMailgun API key (if using Mailgun driver)Runtime
MAILGUN_DOMAINMailgun sending domain (if using Mailgun)Runtime
MAILGUN_REGIONMailgun region: us (default) or euRuntime
STORAGE_DRIVERStorage provider (e.g., toast-driver-storage-s3)Runtime
S3_ENDPOINTS3-compatible endpoint URLRuntime
S3_ACCESS_KEY_IDS3 access key IDRuntime
S3_SECRET_ACCESS_KEYS3 secret access keyRuntime
S3_BUCKETS3 bucket name for uploadsRuntime
S3_REGIONS3 region (defaults to auto)Runtime
S3_PUBLIC_URLPublic base URL for uploaded filesRuntime

Admin Service:

VariablePurposeBuild/Runtime
TIPTAP_PRO_TOKENNPM registry auth for TipTap ProBuild
VITE_API_URLAPI endpoint for frontendBuild

Important: Admin build-time variables (specifically VITE_API_URL) are baked into the JavaScript bundle. If you change them, you must rebuild the image - a restart won't pick up changes.

Docs Service:

VariablePurposeBuild/Runtime
TIPTAP_PRO_TOKENNPM registry auth for TipTap ProBuild

The docs service has no runtime environment variables. All content is statically built into the Next.js standalone output.

See .env.example for the complete list with descriptions, and docs/patterns/docker.md for Docker-specific details.

20. Configure GitHub secrets for PR database reset

The repository includes a workflow that automatically resets PR preview databases when migration files change. This prevents "table already exists" errors.

Add these secrets in GitHub (Settings → Secrets and variables → Actions):

  • RAILWAY_TOKEN: Railway API token (get from Railway dashboard → Account Settings → Tokens)
  • RAILWAY_PROJECT_ID: Your Railway project ID (visible in the project URL: railway.com/project/{PROJECT_ID})

Any secrets needed for CI builds (like private registry tokens) should also be added here. Check .env.example for the full list of variables the app uses.

Without these secrets, the workflow will post a comment asking for manual intervention when migrations change in a PR.

Configuration Files

railway.json (API service)

The root railway.json configures build and deploy settings for the API service:

{
  "$schema": "https://railway.com/railway.schema.json",
  "build": {
    "builder": "DOCKERFILE",
    "watchPatterns": ["apps/api/**", "packages/db/**", "packages/config/**", "Dockerfile"]
  },
  "deploy": {
    "healthcheckPath": "/healthz",
    "healthcheckTimeout": 30
  }
}

apps/admin/railway.json (Admin service)

The admin service has its own railway.json at apps/admin/railway.json:

{
  "$schema": "https://railway.com/railway.schema.json",
  "build": {
    "builder": "DOCKERFILE",
    "dockerfilePath": "apps/admin/Dockerfile.admin",
    "watchPatterns": ["apps/admin/**", "packages/ui/**", "packages/config/**", "pnpm-lock.yaml"]
  },
  "deploy": {
    "healthcheckPath": "/",
    "healthcheckTimeout": 30
  }
}

Key differences from the API config:

  • Uses a separate Dockerfile at apps/admin/Dockerfile.admin
  • Dockerfile path is relative to repo root (no leading slash)
  • Watches packages/ui/** since admin depends on the UI package
  • Serves static files via nginx (no start command needed - nginx runs by default)
  • Health check on / returns the index.html page

Note: Each config file only configures a single service. Multi-service infrastructure requires manual setup or Railway Templates.

apps/docs/railway.json (Docs service)

The docs service has its own railway.json at apps/docs/railway.json:

{
  "$schema": "https://railway.com/railway.schema.json",
  "build": {
    "builder": "DOCKERFILE",
    "dockerfilePath": "apps/docs/Dockerfile.docs",
    "watchPatterns": ["apps/docs/**", "docs/**", "pnpm-lock.yaml"]
  },
  "deploy": {
    "healthcheckPath": "/",
    "healthcheckTimeout": 30
  }
}

Key differences from the other configs:

  • Uses apps/docs/Dockerfile.docs for Next.js standalone builds
  • Watches both apps/docs/** and docs/** (symlinked content from repo root)
  • No start command needed — Next.js standalone runs via node server.js
  • Health check on / returns the docs homepage
  • No environment variables needed at runtime

Alternative: Pre-built Images

Instead of Railway building from Dockerfiles, you can configure it to pull pre-built images from GitHub Container Registry. CI automatically builds and pushes images on every commit.

Benefits

  • Faster deploys: Pull (~30s) vs build (~90s)
  • Consistency: Same image tested in CI is what deploys
  • No rate limiting: Avoids TipTap registry 403 errors from parallel builds

Setup

  1. Go to Railway dashboard → Service → Settings → Source
  2. Change from "GitHub Repo" to "Docker Image"
  3. Set image URL:
    • API: ghcr.io/tryghost/toast-api:main
    • Admin: ghcr.io/tryghost/toast-admin:main
    • Docs: ghcr.io/tryghost/toast-docs:main
  4. If images are private, configure Docker Registry Authentication:
    • Go to Service → Settings → Docker Registry Authentication
    • Create a GitHub Personal Access Token (PAT) with read:packages scope at https://github.com/settings/tokens
    • Fill in the fields:
      • Registry: ghcr.io
      • Username: Your GitHub username
      • Password: The PAT you created
    • Store the PAT securely - Railway encrypts it but you'll need it if reconfiguring

Image Tags

TagUse Case
mainStaging (tracks main branch)
sha-<commit>Pin to specific commit
pr-<number>PR preview environments

Triggering Deploys

After switching to image-based deployment, Railway won't auto-deploy on git push. Options:

  1. Manual: Click "Deploy" in Railway dashboard after CI completes

  2. Webhook: Configure a deploy hook triggered from GitHub Actions

    • In Railway: Service → Settings → Deploy → Generate Deploy Webhook
    • Copy the webhook URL (keep it secret - anyone with the URL can trigger deploys)
    • Add as a GitHub Actions secret: RAILWAY_DEPLOY_WEBHOOK
    • Add a step to your CI workflow:
      - name: Trigger Railway deploy
        if: github.ref == 'refs/heads/main'
        run: curl -X POST "${{ secrets.RAILWAY_DEPLOY_WEBHOOK }}"
    • If the webhook URL is exposed, regenerate it in Railway immediately
  3. Railway CLI/API: Use railway up or the GraphQL API from CI

Limitations

Railway's config-as-code (railway.json) only supports single-service configuration. To define reproducible multi-service infrastructure, options include:

  1. This documentation - Manual setup steps
  2. Railway Templates - Create a publishable template bundling app + database
  3. Railway GraphQL API - Script infrastructure provisioning

For this spike, we use option 1 (documentation).

Troubleshooting

Database connection fails

  1. Verify PostgreSQL service is running in Railway dashboard
  2. Check DATABASE_URL is set on the app service:
    railway variables --service Toast
  3. Ensure the variable uses interpolation syntax: ${{Postgres.DATABASE_URL}}

Build fails

  1. Check build logs in Railway dashboard
  2. Verify Dockerfile exists and builds locally:
    docker build -t toast-api .

Health check fails

Railway uses /healthz for health checks (configured in railway.json). If the app crashes:

  1. Check deployment logs: railway logs --service Toast
  2. Verify the app starts on the correct port (Railway sets PORT env var)

Admin service uses wrong config file

Symptom: Admin builds using the API's Dockerfile, or shows "config file does not exist" error.

Cause: Railway defaults to the root railway.json. For monorepo services with separate configs, you must explicitly set the config path.

Fix:

  1. Go to Railway dashboard → admin service → Settings → Source
  2. Set Config File Path to apps/admin/railway.json (no leading slash)
  3. Set Dockerfile Path to apps/admin/Dockerfile.admin (no leading slash)
  4. Redeploy

Note: These settings must be configured in the dashboard for each service. The railway.json file alone isn't enough - Railway needs to know which config file and Dockerfile to use for each service.

Admin service builds but shows blank page

Symptom: Deployment succeeds but the admin URL shows nothing or a 404.

Possible causes:

  1. Wrong builder: Check build logs - should show nginx, not the API Dockerfile
  2. Missing VITE_API_URL: The env var must be set for runtime config injection
  3. Config path not set: See "Admin service uses wrong config file" above

Fix: Verify the config path is set correctly, then trigger a redeploy.

PR environment migration fails with "table already exists"

Symptom: Railway PR deploy fails with error like relation "tablename" already exists.

Cause: Migration files were modified after the PR environment was created. The database has schema from old migrations, but Drizzle tries to run the new migrations which conflict.

Automatic fix: The reset-pr-database.yml workflow automatically detects migration file changes and deletes the PR environment. Railway recreates it fresh on the next deploy.

Manual fix: If the automatic reset doesn't work:

  1. Go to Railway dashboard → your project
  2. Switch to the PR environment (dropdown at top)
  3. Delete the environment
  4. Trigger a new deploy (push a commit or re-run the Railway deployment workflow); Railway will recreate the environment on that deploy

Required secrets for automatic reset:

The workflow requires these GitHub repository secrets:

  • RAILWAY_TOKEN: Railway API token (get from Railway dashboard → Account Settings → Tokens)
  • RAILWAY_PROJECT_ID: Your Railway project ID (visible in project URL)

Build fails due to missing environment variable

Symptom: Build fails with authentication errors, missing API keys, or "not found" errors for private packages.

Cause: The environment variable is either:

  1. Not set on the service at all
  2. Set on staging but missing from the PR environment (because the PR was created before the variable was added)

Fix:

  1. Check if the variable exists on staging:
    railway link -e staging -s <service>
    railway variables
  2. If missing from staging, add it (see "Adding a New Secret" above)
  3. If the PR environment is missing a variable that staging has, delete the PR environment in Railway dashboard and let it recreate on the next push

On this page