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:
- API - The Hono API application (deployed from GitHub via Dockerfile)
- admin - The React admin panel (deployed from GitHub via Dockerfile + nginx)
- docs - The Fumadocs documentation site (deployed from GitHub via Dockerfile + Next.js standalone)
- 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
mainbranch - 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 logsTesting a PR That Needs New Environment Variables
When testing a PR that introduces new environment variables (e.g., a new integration or driver):
-
Find the PR preview environment name:
Toast-pr-{number}(e.g.,Toast-pr-453) -
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' -
Get the PR preview URLs:
railway domain # Shows API URL railway link -s admin && railway domain # Shows admin URL -
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:
- Add to
.env.example- Document what the variable is for - Set on staging services - Use the CLI to set it on each service that needs it
- Update GitHub secrets - If CI needs it, add to repository secrets
- 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'
doneSetup Steps
1. Install and authenticate Railway CLI
# Install (macOS)
brew install railway
# Login
railway login2. Create a new Railway project
# Create project and link to current directory
railway initOr create via the Railway dashboard.
3. Connect GitHub repository
In the Railway dashboard:
- Go to your project
- Click "New Service" > "GitHub Repo"
- Select the
Toastrepository - Railway will auto-detect the
railway.jsonconfig
4. Add PostgreSQL database
# Link to your project first
railway link
# Deploy PostgreSQL template
railway deploy-template postgresOr 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 ToastOr 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/readySeed 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
| Environment | Migrations | Seeding | Content Persistence |
|---|---|---|---|
| Production | Yes | No | Persistent |
| Staging | Yes | Yes | Ephemeral - reset on every deploy |
| PR Preview | Yes | Yes | Ephemeral - 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/Toast9. Configure admin service settings
In the Railway dashboard, go to the admin service settings:
- 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
- This is critical - without this, Railway uses the root
- Dockerfile Path: Set to
apps/admin/Dockerfile.admin- Do not include a leading slash; Railway treats
/apps/...as absolute and fails
- Do not include a leading slash; Railway treats
- The
railway.jsoninapps/admin/configures:- Dockerfile builder pointing to
apps/admin/Dockerfile.admin - Watch patterns for
apps/admin/**,packages/ui/**, andpackages/config/** - Health check on
/(returns index.html)
- Dockerfile builder pointing to
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
PORTenv via nginx templates - Injects
VITE_API_URLintoconfig.jsat 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 admin12. 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/Toast13. Configure docs service settings
In the Railway dashboard, go to the docs service settings:
- 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
- This is critical - without this, Railway uses the root
- Dockerfile Path: Set to
apps/docs/Dockerfile.docs- Do not include a leading slash; Railway treats
/apps/...as absolute and fails
- Do not include a leading slash; Railway treats
- The
railway.jsoninapps/docs/configures:- Dockerfile builder pointing to
apps/docs/Dockerfile.docs - Watch patterns for
apps/docs/**,docs/**, andpnpm-lock.yaml - Health check on
/(returns the docs homepage)
- Dockerfile builder pointing to
The docs Dockerfile:
- Builds Next.js in standalone mode for a minimal runtime image
- Resolves symlinks from
content/code/to the repo-rootdocs/directory - Serves via Node.js on port 3000 (Railway sets
PORTautomatically) - Requires
TIPTAP_PRO_TOKENat 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 docs15. 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:
| Service | Custom Domain | Railway CNAME Target |
|---|---|---|
| API | api.staging.toast.place | Copy from Railway dashboard |
| Admin | admin.staging.toast.place | Copy from Railway dashboard |
| Docs | docs.staging.toast.place | Copy 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:
- A CNAME record pointing to the Railway-provided target
- 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.place17. 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:
- Login sets a cookie on the API domain with
SameSite=Lax(default) - Requests from admin are treated as cross-site
SameSite=Laxcookies aren't sent on cross-site fetch requests- 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.app18. 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:
| Variable | Purpose | Build/Runtime |
|---|---|---|
DATABASE_URL | PostgreSQL connection string | Runtime |
BETTER_AUTH_SECRET | Session signing key | Runtime |
TIPTAP_PRO_TOKEN | NPM registry auth for TipTap Pro | Build |
TIPTAP_COLLAB_SECRET | JWT signing for collaboration tokens | Runtime |
TIPTAP_AI_SECRET | JWT signing for AI tokens | Runtime |
VITE_TIPTAP_COLLAB_APP_ID | Returned in collaboration token response | Runtime |
VITE_TIPTAP_AI_APP_ID | Returned in AI token response | Runtime |
ADMIN_URL | Admin panel URL for CORS | Runtime |
BETTER_AUTH_URL | API base URL used by Better Auth | Runtime |
COOKIE_DOMAIN | Optional legacy cookie domain variable (currently unused by API code) | Runtime |
CROSS_SITE_COOKIES | Enable SameSite=None for cross-site Railway previews | Runtime |
EMAIL_DRIVER | Email provider (e.g., toast-driver-email-mailgun) | Runtime |
EMAIL_FROM | Default sender address for transactional emails | Runtime |
MAILGUN_API_KEY | Mailgun API key (if using Mailgun driver) | Runtime |
MAILGUN_DOMAIN | Mailgun sending domain (if using Mailgun) | Runtime |
MAILGUN_REGION | Mailgun region: us (default) or eu | Runtime |
STORAGE_DRIVER | Storage provider (e.g., toast-driver-storage-s3) | Runtime |
S3_ENDPOINT | S3-compatible endpoint URL | Runtime |
S3_ACCESS_KEY_ID | S3 access key ID | Runtime |
S3_SECRET_ACCESS_KEY | S3 secret access key | Runtime |
S3_BUCKET | S3 bucket name for uploads | Runtime |
S3_REGION | S3 region (defaults to auto) | Runtime |
S3_PUBLIC_URL | Public base URL for uploaded files | Runtime |
Admin Service:
| Variable | Purpose | Build/Runtime |
|---|---|---|
TIPTAP_PRO_TOKEN | NPM registry auth for TipTap Pro | Build |
VITE_API_URL | API endpoint for frontend | Build |
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:
| Variable | Purpose | Build/Runtime |
|---|---|---|
TIPTAP_PRO_TOKEN | NPM registry auth for TipTap Pro | Build |
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.docsfor Next.js standalone builds - Watches both
apps/docs/**anddocs/**(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
- Go to Railway dashboard → Service → Settings → Source
- Change from "GitHub Repo" to "Docker Image"
- Set image URL:
- API:
ghcr.io/tryghost/toast-api:main - Admin:
ghcr.io/tryghost/toast-admin:main - Docs:
ghcr.io/tryghost/toast-docs:main
- API:
- If images are private, configure Docker Registry Authentication:
- Go to Service → Settings → Docker Registry Authentication
- Create a GitHub Personal Access Token (PAT) with
read:packagesscope at https://github.com/settings/tokens - Fill in the fields:
- Registry:
ghcr.io - Username: Your GitHub username
- Password: The PAT you created
- Registry:
- Store the PAT securely - Railway encrypts it but you'll need it if reconfiguring
Image Tags
| Tag | Use Case |
|---|---|
main | Staging (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:
-
Manual: Click "Deploy" in Railway dashboard after CI completes
-
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
-
Railway CLI/API: Use
railway upor 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:
- This documentation - Manual setup steps
- Railway Templates - Create a publishable template bundling app + database
- Railway GraphQL API - Script infrastructure provisioning
For this spike, we use option 1 (documentation).
Troubleshooting
Database connection fails
- Verify PostgreSQL service is running in Railway dashboard
- Check
DATABASE_URLis set on the app service:railway variables --service Toast - Ensure the variable uses interpolation syntax:
${{Postgres.DATABASE_URL}}
Build fails
- Check build logs in Railway dashboard
- Verify
Dockerfileexists 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:
- Check deployment logs:
railway logs --service Toast - Verify the app starts on the correct port (Railway sets
PORTenv 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:
- Go to Railway dashboard → admin service → Settings → Source
- Set Config File Path to
apps/admin/railway.json(no leading slash) - Set Dockerfile Path to
apps/admin/Dockerfile.admin(no leading slash) - 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:
- Wrong builder: Check build logs - should show nginx, not the API Dockerfile
- Missing VITE_API_URL: The env var must be set for runtime config injection
- 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:
- Go to Railway dashboard → your project
- Switch to the PR environment (dropdown at top)
- Delete the environment
- 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:
- Not set on the service at all
- Set on staging but missing from the PR environment (because the PR was created before the variable was added)
Fix:
- Check if the variable exists on staging:
railway link -e staging -s <service> railway variables - If missing from staging, add it (see "Adding a New Secret" above)
- 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