Railway Integration Guide

Technology: railway · Category: hosting · Last reviewed: 2026-09-05

Source: https://tech-stack.codeamanilabs.org/guide/railway

Insight:

Railway runs always-on containers, not serverless functions — reach for it when a process must outlive a request: queue consumers, SKIP LOCKED pollers, WebSocket servers, long-lived M-Pesa reconciliation workers. Vercel stays the default for the Next.js front end; Railway hosts the worker beside it, with managed Postgres/Redis on the same private network.

██████╗  █████╗ ██╗██╗     ██╗    ██╗ █████╗ ██╗   ██╗
██╔══██╗██╔══██╗██║██║     ██║    ██║██╔══██╗╚██╗ ██╔╝
██████╔╝███████║██║██║     ██║ █╗ ██║███████║ ╚████╔╝
██╔══██╗██╔══██║██║██║     ██║███╗██║██╔══██║  ╚██╔╝
██║  ██║██║  ██║██║███████╗╚███╔███╔╝██║  ██║   ██║
╚═╝  ╚═╝╚═╝  ╚═╝╚═╝╚══════╝ ╚══╝╚══╝ ╚═╝  ╚═╝   ╚═╝

Railway Integration Guide

Focus — Deploying long-running services, workers, cron jobs and managed databases on Railway from Claude Code: CLI, config-as-code, the public GraphQL API, and the private-network topology that keeps egress costs at zero.

Overview

Railway is a container hosting platform. You point it at a repo, it builds an OCI image (via Railpack, the successor to Nixpacks) and runs it as a long-lived process with a public HTTPS domain, TLS, and health-checked zero-downtime deploys.

The distinction that matters when choosing it:

Shape Platform Why
Next.js app, request/response Vercel Serverless is the right fit; keep the default
Static site + edge functions Netlify / Cloudflare Edge-first
Process that must outlive a request Railway Queue consumers, pollers, WebSocket servers, schedulers
Managed Postgres + Redis on one private network Railway One project, one private network, no egress fees between services

A Railway project contains services (each a deployed container) across environments (production, staging, PR environments). Services in the same project and environment reach each other over a private IPv6 network — traffic there is free and never leaves Railway.

Official Documentation

Topic URL
Docs home https://docs.railway.com
Config as code (railway.json / .toml) https://docs.railway.com/reference/config-as-code
Public GraphQL API https://docs.railway.com/reference/public-api
CLI reference https://docs.railway.com/reference/cli-api
Private networking https://docs.railway.com/reference/private-networking
Variables & reference variables https://docs.railway.com/guides/variables
Cron jobs https://docs.railway.com/guides/cron-jobs
Pricing & plan limits https://docs.railway.com/reference/pricing/plans
JSON schema (editor autocomplete) https://railway.com/railway.schema.json

CLI setup

# Install (v5.49.2 at time of review)
npm install -g @railway/cli

railway login            # opens a browser; use `railway login --browserless` over SSH
railway init             # create a new project from the current directory
railway link             # or: attach this directory to an existing project

railway up               # build + deploy, streaming logs
railway up -d            # detached — don't stream
railway up --ci          # CI mode: no interactive prompts
railway up --service my-api --environment staging

Useful day-to-day commands:

railway add                        # add a service or a database (Postgres, Redis, MySQL, Mongo)
railway variables                  # list variables for the linked service
railway variables set KEY=value    # set one
railway run -- npm run dev         # run locally WITH the remote environment's variables injected
railway logs                       # tail deploy/runtime logs
railway open                       # open the project dashboard

railway run is the one to remember: it injects the live environment's variables into a local process, so local dev hits the same database and secrets as the deployed service without ever copying them into a .env file.

Config as code

Commit a railway.json (or railway.toml) next to your service. It overrides dashboard settings, so infrastructure changes ship in the same PR as the code.

{
  "$schema": "https://railway.com/railway.schema.json",
  "build": {
    "builder": "RAILPACK"
  },
  "deploy": {
    "startCommand": "node dist/worker.js",
    "preDeployCommand": "npm run db:migrate",
    "healthcheckPath": "/health",
    "healthcheckTimeout": 300,
    "restartPolicyType": "ON_FAILURE",
    "restartPolicyMaxRetries": 10
  }
}

The TOML form is equivalent:

[build]
builder = "railpack"
buildCommand = "npm run build"

[deploy]
preDeployCommand = ["npm run db:migrate"]
startCommand = "node dist/worker.js"
healthcheckPath = "/health"
healthcheckTimeout = 300
restartPolicyType = "on_failure"

Key fields:

railway.toml does not support volume-mount configuration — use railway.json or the dashboard for volumes.

The PORT contract

Railway injects a PORT environment variable and expects your server to bind it on 0.0.0.0. Hardcoding a port is the single most common cause of a service that builds fine and then fails its health check.

const port = Number(process.env.PORT) || 3000;
app.listen(port, "0.0.0.0", () => console.log(`listening on ${port}`));

If your app cannot listen on PORT (for example when using target ports), set a PORT variable explicitly so Railway probes the right one.

Variables, references and private networking

Railway variables are per-service, per-environment. Reference variables interpolate one service's value into another's, so a connection string is never copy-pasted:

# In the app service, referencing the Postgres service in the same project:
DATABASE_URL=${{Postgres.DATABASE_URL}}
REDIS_URL=${{Redis.REDIS_URL}}

# Reference another service's private address:
API_URL=http://${{api.RAILWAY_PRIVATE_DOMAIN}}:3000

Prefer the private form. Every service gets a RAILWAY_PRIVATE_DOMAIN resolvable only inside the project's IPv6 network:

Bind private listeners to IPv6 (::) — a server listening only on 0.0.0.0 is unreachable over the private network.

Railway also injects RAILWAY_ENVIRONMENT, RAILWAY_SERVICE_NAME, RAILWAY_PUBLIC_DOMAIN and RAILWAY_GIT_COMMIT_SHA — useful for tagging Sentry releases and structured logs.

Public GraphQL API

One endpoint: POST https://backboard.railway.com/graphql/v2.

Token type Header Scope
Account / Workspace / OAuth Authorization: Bearer <TOKEN> Account or workspace-wide
Project token Project-Access-Token: <TOKEN> A single environment in one project
curl --request POST \
  --url https://backboard.railway.com/graphql/v2 \
  --header "Project-Access-Token: $RAILWAY_PROJECT_TOKEN" \
  --header 'Content-Type: application/json' \
  --data '{"query":"query { projectToken { projectId environmentId } }"}'

From TypeScript — note fetch, never a shell-based HTTP call:

const RAILWAY_API = "https://backboard.railway.com/graphql/v2";

export async function railwayQuery<T>(
  query: string,
  variables: Record<string, unknown> = {},
): Promise<T> {
  const token = process.env.RAILWAY_API_TOKEN;
  if (!token) throw new Error("RAILWAY_API_TOKEN is not set");

  const res = await fetch(RAILWAY_API, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      Authorization: `Bearer ${token}`,
    },
    body: JSON.stringify({ query, variables }),
  });

  if (res.status === 429) {
    const retryAfter = res.headers.get("Retry-After") ?? "60";
    throw new Error(`Railway rate limit hit; retry after ${retryAfter}s`);
  }
  if (!res.ok) throw new Error(`Railway API ${res.status}: ${await res.text()}`);

  const body = (await res.json()) as { data?: T; errors?: { message: string }[] };
  if (body.errors?.length) throw new Error(body.errors.map((e) => e.message).join("; "));
  if (!body.data) throw new Error("Railway API returned no data");
  return body.data;
}

Rate limits (responses carry X-RateLimit-Limit, -Remaining, -Reset):

Plan Per hour Per second
Free 100 —
Hobby 1,000 10
Pro 10,000 50

Cron jobs

Set a Cron Schedule on a service and Railway runs its start command on that schedule. The rules are strict and worth internalising:

*/15 * * * *   # every 15 minutes
0 3 * * *      # 03:00 UTC daily  (= 06:00 EAT; Kenya is UTC+3 year-round)

Because the schedule is UTC and East Africa Time has no DST, an EAT-local job is a fixed −3h offset — 0 3 * * * is reliably 6am in Nairobi.

CI/CD with GitHub Actions

Deploy on green tests using a project token stored as a repository secret:

# .github/workflows/railway-deploy.yml
name: Deploy to Railway
on:
  push:
    branches: [master]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      - run: npm test
      - name: Deploy
        run: |
          npm install -g @railway/cli
          railway up --ci --service "$RAILWAY_SERVICE"
        env:
          RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
          RAILWAY_SERVICE: api

RAILWAY_TOKEN in the environment authenticates the CLI non-interactively — no railway login step. Scope it to a project token so a leaked CI secret can touch exactly one environment.

Pricing model

Usage-based, billed per minute of actual consumption:

Resource Rate
Memory $10 / GB / month ($0.000231 / GB / min)
vCPU $20 / vCPU / month ($0.000463 / vCPU / min)
Network egress $0.05 / GB
Plan Subscription Included usage Per-service ceiling
Hobby $5/mo $5 48 GB RAM, 48 vCPU, 6 replicas
Pro $20/mo $20 1 TB RAM, 1,000 vCPU, 42 replicas
Enterprise Custom Custom 2.4 TB RAM, 2,400 vCPU, 50 replicas

The subscription includes an equal amount of usage, so a small always-on worker on Hobby is often fully covered. Because billing tracks provisioned resources over time, an idle service still costs memory — size containers deliberately rather than leaving defaults.

codeAmani notes

Security

Where Railway fits our stack

Vercel remains the default for Next.js front ends. Railway earns its place for the part Vercel structurally cannot host — a process that outlives a request:

Kenya-targeted projects

Provenance

A Railway deploy is a deploy, not a downloadable artifact — per our SLSA policy that means no provenance target. Pin the GitHub Actions used in the deploy workflow to commit SHAs and document the build; there is nothing for slsa-verifier to verify. If a project additionally ships a container image or release tarball, that artifact takes Build L3 on its own track — see supply-chain/CLAUDE_CODE_INTEGRATION.md.

Troubleshooting

Symptom Cause Fix
Build succeeds, deploy never activates Health check never returns 200 Bind process.env.PORT on 0.0.0.0; confirm healthcheckPath exists and is unauthenticated
service unavailable on health check Hardcoded port, or target ports in use Bind PORT, or set a PORT variable telling Railway which port to probe
Service unreachable over private network Listening on 0.0.0.0 only Bind IPv6 (::) for private-network traffic
Cron job never fires again Previous run never exited Close DB pools and exit; overlapping runs are skipped, not queued
Cron fires less often than expected Interval below the floor Minimum is 5 minutes
Unexpected egress charges Services talking over public domains Switch to RAILWAY_PRIVATE_DOMAIN / reference variables
429 from the GraphQL API Plan rate limit Honour Retry-After; batch queries
Migrations race the new deploy Migration in startCommand Move it to deploy.preDeployCommand

Official docs: