← All posts
·18 min read

Claude Code with Railway: The Deployment Setup Guide (2026)

Claude CodeRailwayDeploymentWorkflow
Claude Code with Railway: Services, Postgres, Cron, and Deployment Config

Why Railway needs its own CLAUDE.md before you write a line of config

Railway is the deployment platform most indie developers reach for after they outgrow Render's free tier and before they need Kubernetes. It runs services in containers, provisions Postgres and Redis with one click, handles private networking between services automatically, and lets you deploy from GitHub pushes or the CLI without touching Docker directly. The gap between what Railway can do and what Claude Code generates without guidance is significant.

Claude knows Railway. It knows railway.json, it knows nixpacks, and it knows the Railway CLI. What it does not know is your project's specific decisions: which service runs the web process, which runs the worker, how your Postgres connection string is referenced across services, whether your cron job runs in a separate service or as a Railway cron schedule, and whether your builds are Nixpacks-detected or explicitly configured.

Without CLAUDE.md, Claude generates Railway config that works in isolation and fails in production. It puts DATABASE_URL as a hardcoded string instead of a Railway service reference. It mixes build and start commands. It treats your monorepo as a single service. It generates a nixpacks.toml with the wrong provider when Nixpacks auto-detection would have been correct, or with no provider config when Railway cannot auto-detect your runtime.

This guide covers the CLAUDE.md configuration that prevents those failures. If you are new to Claude Code, the Claude Code setup guide covers installation and initial project configuration first. For the environment variable patterns that apply across all platforms, Claude Code with environment variables covers the mechanics in detail.

How Railway's project model maps to CLAUDE.md

Railway organises everything into a project, and each project has one or more services. A typical setup has three services running side by side: your web application, a PostgreSQL database, and optionally a Redis instance. Each service has its own environment variables, build configuration, and scaling settings. Private networking between services happens automatically on Railway's internal network.

The first thing CLAUDE.md needs to establish is the project structure, because Claude Code cannot infer it from your application code. A backend API, a background worker, and a Postgres database all live in the same Railway project, but they are separate services with separate config. Without this documented, Claude generates a single railway.json that tries to run everything as one service.

Add a services block to CLAUDE.md before any Railway-specific rules:

# Railway project structure

## Services in this project
- api: Node.js web service, port 3000, deployed from /api directory
- worker: Node.js background worker, no public port, deployed from /api directory
- postgres: Railway Postgres add-on (managed, not a custom service)

## Environment references
- DATABASE_URL is NOT a hardcoded string
- Use ${{Postgres.DATABASE_URL}} when referencing the Postgres add-on URL in other services
- Use ${{api.RAILWAY_PRIVATE_DOMAIN}} when one service calls another over private networking
- RAILWAY_ENVIRONMENT is injected automatically (production / staging)

## Deployment source
- Deploy from GitHub, main branch triggers production
- Railway CLI (railway up) is used for one-off manual deploys only
- Each service is configured independently via service-level settings in the Railway dashboard

The ${{Postgres.DATABASE_URL}} syntax is the most important Railway-specific rule to put in CLAUDE.md. When Claude generates an environment variable for your database connection, it defaults to a literal string. On Railway, service references propagate automatically: the Postgres add-on injects its connection string as a variable, and other services reference it using the ${{ServiceName.VARIABLE}} syntax. Hardcoding the string breaks the reference chain and means you manually update it every time Railway rotates credentials.

The Railway CLAUDE.md template

With the project structure established, the full CLAUDE.md template covers build configuration, environment variable handling, service networking, and the Railway-specific patterns that Claude Code must follow.

# Railway deployment rules

## Stack
- Runtime: Node.js 20 LTS (or specify: Python 3.12 / Go 1.22)
- Package manager: npm (or pnpm / yarn, pick one, document it)
- Builder: Nixpacks (auto-detect by default, override only when needed)
- Railway CLI version: latest (railway --version to confirm)

## Project services
- api: web service, listens on process.env.PORT (Railway injects PORT automatically)
- worker: background service, no HTTP listener, reads from queue
- postgres: Railway Postgres add-on (managed)
- redis: Railway Redis add-on (managed, if present)

## railway.json rules
- Set in the service root (same directory as package.json or equivalent)
- Separate railway.json per service when deploying a monorepo
- build.builder: "NIXPACKS" (uppercase) unless using a Dockerfile
- build.buildCommand: only set when you need a step beyond Nixpacks auto-detect
  Examples: "npm run build", "prisma generate && npm run build"
- deploy.startCommand: always set explicitly, never rely on auto-detect for this
  Examples: "node dist/server.js", "npm start", "python -m uvicorn app.main:app"
- deploy.restartPolicyType: "ON_FAILURE" for production, "NEVER" for cron
- deploy.healthcheckPath: "/health" for web services, omit for workers
- deploy.healthcheckTimeout: 120 (seconds, give enough time for cold start)

## Environment variable rules
- NEVER hardcode DATABASE_URL or REDIS_URL in railway.json or config files
- Reference Railway add-on variables: ${{Postgres.DATABASE_URL}}, ${{Redis.REDIS_URL}}
- Reference cross-service variables: ${{api.RAILWAY_PRIVATE_DOMAIN}}
- RAILWAY_ENVIRONMENT, PORT, RAILWAY_SERVICE_NAME are injected automatically
- Do NOT set PORT in your railway.json, Railway injects it per service
- .env files are for local development only, NEVER committed, NEVER deployed
- All production secrets live in Railway dashboard variables, not in any config file

## Private networking
- Services in the same Railway project share a private network automatically
- Internal hostnames: ${{ServiceName.RAILWAY_PRIVATE_DOMAIN}}
- Internal port: the port the service listens on (not the public port)
- Example: worker calls api at http://${{api.RAILWAY_PRIVATE_DOMAIN}}:3000/internal/process
- Use private networking for all service-to-service calls, never the public URL

## Build configuration
- Nixpacks detects Node.js from package.json, Python from requirements.txt or Pipfile
- Override Nixpacks detection only when auto-detect fails or produces wrong provider
- nixpacks.toml at service root overrides detection
- Do NOT add nixpacks.toml unless you have a concrete reason auto-detect fails

## Cron jobs
- Short recurring tasks: use Railway cron service (separate service, deploy.cronSchedule)
- Long-running scheduled tasks: use dedicated worker service with internal scheduler
- Railway cron format: standard cron syntax "0 9 * * *" (9am UTC daily)
- Cron services set deploy.restartPolicyType: "NEVER"

## Hard rules
- NEVER set DATABASE_URL to a literal string in any Railway environment variable
- NEVER listen on a hardcoded port, always use process.env.PORT
- NEVER reference the public service URL for internal calls, use private networking
- NEVER commit .env files, all secrets in Railway dashboard
- NEVER deploy a single service that runs both web and worker processes

That template covers the seven decisions Railway deployments require that Claude Code cannot infer from application code alone. The density serves the same purpose it does in the Electron template: Claude reads CLAUDE.md once per session, and every constraint you omit is a decision Claude makes with framework defaults rather than your project's intent.

railway.json for a Node.js API service

With the rules established, here is the railway.json file Claude should generate for a Node.js API service. Put this shape in your CLAUDE.md as a reference pattern:

{
  "$schema": "https://railway.com/railway.schema.json",
  "build": {
    "builder": "NIXPACKS",
    "buildCommand": "npm ci && npm run build"
  },
  "deploy": {
    "startCommand": "node dist/server.js",
    "restartPolicyType": "ON_FAILURE",
    "restartPolicyMaxRetries": 5,
    "healthcheckPath": "/health",
    "healthcheckTimeout": 120
  }
}

Three things in this config prevent the most common Claude failures.

buildCommand separates install from build explicitly. Without it, Nixpacks runs npm install as part of its auto-detected build and may skip your TypeScript compilation step. Including npm ci in the build command ensures a clean, reproducible install from package-lock.json. If you use Prisma, this is also where prisma generate goes: "npm ci && npx prisma generate && npm run build".

startCommand is always explicit. Claude sometimes generates no startCommand and relies on Nixpacks to detect the start command from your package.json scripts. This works when Nixpacks detects correctly, fails silently when it does not, and is always a debugging session waiting to happen. Set it explicitly.

healthcheckTimeout at 120 seconds handles cold starts for Node.js services that perform database migrations on startup. The default Railway timeout is shorter, and services that run Prisma migrations before the HTTP server starts will fail their first healthcheck and restart in a loop. If your service runs migrations on boot, 120 seconds gives the migration enough time to complete before Railway marks the deploy unhealthy.

nixpacks.toml: when to use it and what Claude gets wrong

Nixpacks is Railway's build system. It detects your runtime from filesystem signals (presence of package.json, requirements.txt, go.mod, pyproject.toml) and generates a container image without a Dockerfile. For most projects, Nixpacks auto-detection is correct and you should not add a nixpacks.toml. The rule in CLAUDE.md is explicit: "Do NOT add nixpacks.toml unless you have a concrete reason auto-detect fails."

Claude generates nixpacks.toml by default when asked to set up Railway configuration. It should not. Here are the three cases where nixpacks.toml is correct:

Runtime version pinning. When you need a specific Node.js version that Nixpacks would not select by default, or when you need additional system packages.

Python with a specific provider. When your Python project uses pyproject.toml and Poetry, and Nixpacks auto-detects the wrong provider (plain pip instead of Poetry).

Multi-language services. When your service requires both Node.js and Python (less common, but happens with ML inference services that use a Python model server with a Node.js API wrapper).

# nixpacks.toml: only add this file when auto-detect is insufficient

[phases.setup]
nixPkgs = ["nodejs-20_x", "python312"]

[phases.install]
cmds = ["npm ci", "pip install -r requirements.txt"]

[phases.build]
cmds = ["npm run build"]

[start]
cmd = "node dist/server.js"

If your project is pure Node.js 20 on an LTS version, you do not need this file. Nixpacks detects Node.js from package.json and selects the version from .node-version or .nvmrc if present. Pin your Node version there, not in nixpacks.toml.

Add to CLAUDE.md:

## nixpacks.toml guidance
- Do NOT generate nixpacks.toml for pure Node.js, Python, or Go projects
- Node version: set in .node-version or .nvmrc at project root (Nixpacks reads both)
- Python version: set in .python-version at project root
- Only add nixpacks.toml when: multi-language service, missing system dependency,
  or Nixpacks provider detection fails after confirming with `nixpacks plan`

The nixpacks plan command is worth noting in CLAUDE.md because it shows what Nixpacks would do with your project before you deploy. When debugging a build failure, run nixpacks plan . locally and compare the detected provider and install/build/start commands against what your project actually needs. That diff tells you exactly what to put in nixpacks.toml.

For Docker-based deployments where you need more control than Nixpacks allows, the patterns in Claude Code with Docker apply directly since Railway accepts Dockerfiles as an alternative builder.

Environment variables: Railway dashboard versus .env

Environment variable handling is where Claude Code diverges most from what Railway actually needs. The pattern Claude uses for local development is .env with dotenv loading. The pattern Railway uses for production is variables defined in the dashboard and injected automatically into running containers. The two patterns must never cross.

This is the rule that matters most in your CLAUDE.md:

## Environment variable workflow

### Local development
- .env.local for local credentials (never committed)
- dotenv or equivalent to load .env.local for local dev server
- .env.example committed with placeholder values for every required variable

### Railway production
- ALL secrets and configuration in Railway dashboard (service Variables tab)
- Claude Code NEVER writes a .env file with production values
- Claude Code NEVER commits secrets to any config file
- Service references: ${{Postgres.DATABASE_URL}}, ${{Redis.REDIS_URL}}
- Cross-service: ${{api.RAILWAY_PRIVATE_DOMAIN}} for internal API calls

### Pattern for code that reads env vars
const config = {
  databaseUrl: process.env.DATABASE_URL,   // injected by Railway, never hardcoded
  port: parseInt(process.env.PORT ?? '3000'), // Railway injects PORT per service
  redisUrl: process.env.REDIS_URL,          // injected by Railway Redis add-on
  nodeEnv: process.env.NODE_ENV ?? 'development',
  railwayEnv: process.env.RAILWAY_ENVIRONMENT, // 'production' | 'staging'
};

RAILWAY_ENVIRONMENT is injected automatically and distinguishes production from staging deployments. Use it for environment-specific behaviour rather than relying on NODE_ENV alone, because Railway sets NODE_ENV=production in both production and staging environments. If you need to enable debug logging in staging but not production, check process.env.RAILWAY_ENVIRONMENT === 'staging'.

PORT is the other critical injected variable. Railway assigns a port to each service and injects it as PORT. Every service must listen on process.env.PORT. Claude Code sometimes generates hardcoded app.listen(3000) without the environment variable fallback. The CLAUDE.md rule makes the pattern explicit and Claude generates the correct code when the rule is present.

For the complete treatment of environment variable patterns across frameworks including secrets scanning and the .env.example convention, Claude Code with environment variables covers the full workflow.

Cron jobs on Railway

Railway cron services are a separate service type in your project. They deploy the same code as your worker service but run on a schedule instead of continuously. The configuration difference from a normal service is two settings: deploy.cronSchedule and deploy.restartPolicyType: "NEVER".

Put the cron service pattern in CLAUDE.md:

## Railway cron service configuration

### When to use Railway cron vs internal scheduler
- Railway cron: short tasks < 10 minutes, simple schedule, stateless execution
- Internal scheduler (node-cron, APScheduler): complex schedules, stateful tasks,
  tasks that need access to in-memory service state

### railway.json for a cron service
{
  "$schema": "https://railway.com/railway.schema.json",
  "build": {
    "builder": "NIXPACKS",
    "buildCommand": "npm ci && npm run build"
  },
  "deploy": {
    "startCommand": "node dist/jobs/daily-digest.js",
    "restartPolicyType": "NEVER",
    "cronSchedule": "0 9 * * *"
  }
}

### Cron service rules
- restartPolicyType must be "NEVER" for cron services
- cronSchedule uses UTC timezone always
- Cron service shares the same repo as the main api service
- Deploy root for cron is the same as api (both read from /dist/)
- Cron service gets its own environment variables tab in Railway dashboard
- DATABASE_URL for cron service: ${{Postgres.DATABASE_URL}} same as api

The restartPolicyType: "NEVER" is not optional for cron services. Railway expects a cron service to start, do its work, and exit. If you set ON_FAILURE, Railway watches for the process to keep running and tries to restart it when it exits cleanly after completing its task. That produces restart loops and Ghost service entries in your Railway dashboard. The cron schedule only triggers when the process is not already running.

Private networking between services

Private networking is one of Railway's most useful features and the one most likely to be ignored by Claude without CLAUDE.md rules. Services in the same Railway project communicate over a private network without going through the public internet. The connection is faster, free, and does not count against your egress bandwidth.

The private hostname for any service is available as ${{ServiceName.RAILWAY_PRIVATE_DOMAIN}}. For a service named api, another service references it as ${{api.RAILWAY_PRIVATE_DOMAIN}} and connects on the internal port the service listens on.

## Private networking rules

- All service-to-service HTTP calls use private networking
- Internal URL format: http://${{api.RAILWAY_PRIVATE_DOMAIN}}:3000/path
- Worker calls api for internal endpoints: http://${{api.RAILWAY_PRIVATE_DOMAIN}}:3000/internal/*
- NEVER use the public railway.app URL for internal calls
- Internal endpoints on the api service should be protected:
  - Check for x-internal-secret header OR
  - Restrict to calls from within the private network (check RAILWAY_ENVIRONMENT)

### Database connection from api service
DATABASE_URL=${{Postgres.DATABASE_URL}}

### Redis connection from worker service  
REDIS_URL=${{Redis.REDIS_URL}}

### Api URL from worker service (for internal callbacks)
API_INTERNAL_URL=http://${{api.RAILWAY_PRIVATE_DOMAIN}}:3000

Claude Code generates hardcoded hostnames or public URLs for service communication when no private networking rules are present. The hardcoded hostname works in development and breaks the moment you deploy to a Railway environment where service names differ or private domains change.

For services that need to communicate with external PostgreSQL databases, the connection pooling and migration patterns in Claude Code with PostgreSQL apply directly to Railway-managed Postgres since Railway uses standard PostgreSQL.

Common Claude failure modes on Railway

Without CLAUDE.md, five failure modes appear consistently across Railway deployments.

Hardcoded DATABASE_URL. Claude generates DATABASE_URL=postgresql://user:password@host:5432/dbname as a literal in railway.json or suggests setting it manually in the Railway dashboard as a string. The correct pattern is DATABASE_URL=${{Postgres.DATABASE_URL}} in the Railway service variables, which reads from the managed Postgres add-on and updates automatically when Railway rotates credentials. The CLAUDE.md rule blocks the hardcoded string.

Wrong build/start command split. Claude either puts everything in startCommand ("npm install && npm run build && node dist/server.js") or relies entirely on Nixpacks detection and omits startCommand. The correct split separates concerns: buildCommand handles install and compilation, startCommand handles process startup. Mixing them into startCommand slows deploys because install runs on every container start, not just on new builds.

Hardcoded PORT. Claude generates app.listen(3000) or app.listen(parseInt(process.env.PORT || '3000')) but without documenting that Railway injects PORT per service. On Railway, different services on the same project get different port assignments. The process.env.PORT usage is correct, but without the CLAUDE.md rule, Claude generates the hardcoded fallback and developers assume port 3000 is always correct.

Monolithic service config for multi-service projects. When asked to set up Railway for a project with a web server and a background worker, Claude generates a single railway.json that starts both processes. Railway expects one process per service. The correct architecture is two services in the same Railway project, each with their own railway.json and their own Railway dashboard configuration. The services block in CLAUDE.md prevents this.

Missing healthcheck on migration-heavy services. Claude generates no healthcheckPath or uses the default Railway healthcheck timeout for services that run database migrations on startup. Prisma migrations on a cold start can take 15 to 45 seconds. Without an explicit healthcheckTimeout: 120, Railway marks the deploy as failed before migrations complete and cycles the container, which runs migrations again, which takes too long, which Railway marks as failed. The loop continues until you increase the timeout or remove the migration-on-start pattern.

Persistent volumes and stateful services

Railway supports persistent volumes for stateful services that need filesystem storage that survives deploys. File upload storage, SQLite databases, and generated asset caches are the common use cases.

## Persistent volumes

- Railway volumes mount at a path you specify (e.g., /data)
- Volume data persists across deploys and restarts for the same service
- Volume path should be configured as an environment variable, not hardcoded
- VOLUME_PATH=/data (set in Railway dashboard, not in code)

### Rules for volume usage
- Do NOT use volumes as a substitute for a proper database
- Use volumes for: file uploads, SQLite (if intentional), generated asset cache
- When using SQLite on a Railway volume:
  DATABASE_URL=file:${VOLUME_PATH}/app.db
- NEVER write to volume path in build phase, only in start phase

### Scaling limitation
- Railway volumes attach to one service replica only
- If you scale a service to multiple replicas, only one replica gets the volume mount
- Design stateful services to run at max 1 replica, or use S3/R2 for shared file storage

The scaling limitation is critical to document. Railway volumes use single-attachment storage. If you scale your API service to three replicas, only one replica gets the volume mount and the others run without it. This produces intermittent failures that are difficult to debug because they depend on which replica handles the request. The rule to use S3 or Cloudflare R2 for shared file storage prevents this class of bug.

Staging environments and PR deploys

Railway's environment system creates isolated copies of your project per environment. A staging environment has its own Postgres instance, its own environment variables, and its own deployed services. PR deploys create ephemeral environments for each pull request.

## Railway environments

### Environment structure
- production: live traffic, main branch
- staging: internal QA, deploy manually or on PR merge to staging branch
- pr-{number}: ephemeral, created automatically on PR open

### Environment variable overrides
- Base variables set at project level apply to all environments
- Override at environment level for env-specific values
- RAILWAY_ENVIRONMENT injected automatically: 'production' | 'staging' | 'pr-123'

### Database per environment
- Each environment gets its own Postgres instance
- Never share databases between environments
- Staging migrations run before production to verify schema changes

### What Claude should NOT do
- NEVER generate code that checks for specific Railway environment names in business logic
- Feature flags and environment behaviour belong in environment variables, not code strings
- Use process.env.FEATURE_X = 'true' pattern, not if (railwayEnv === 'staging')

The rule against checking environment names in code is a code quality rule as much as a deployment rule. If your code contains if (process.env.RAILWAY_ENVIRONMENT === 'staging') branches in business logic, you have created environment-specific behaviour that is invisible in code review and untestable in isolation. Environment variables named for the feature they control (ENABLE_DEBUG_ENDPOINTS=true) are the correct abstraction.

For the broader patterns of how Claude Code handles multi-environment TypeScript projects, the conventions in Claude Code with TypeScript apply to the type-safety around environment variable access.

What Claude Code handles well on Railway

With the CLAUDE.md above in place, several Railway tasks Claude Code performs reliably without intervention.

Generating railway.json for a new service in an existing project is consistent. Claude follows the buildCommand / startCommand split, sets the correct healthcheck path, and references environment variables by name rather than hardcoding values when the pattern is established in CLAUDE.md.

Adding a Railway cron service for an existing job script is accurate. Claude generates the correct cronSchedule and restartPolicyType: "NEVER" combination when the cron service pattern is in CLAUDE.md.

Writing the health endpoint that Railway checks is reliable. Claude generates a /health route that returns 200 with a JSON body and optionally checks database connectivity, fitting the pattern Railway expects for the healthcheckPath config.

Configuring Prisma for Railway deployment is correct with the migration-in-build pattern:

{
  "build": {
    "buildCommand": "npm ci && npx prisma generate && npm run build"
  },
  "deploy": {
    "startCommand": "node dist/server.js"
  }
}

Note that prisma migrate deploy does not go in buildCommand. It goes in a release command or a dedicated migration step, because Railway runs the build phase in a different container from the running service. Running migrations in the build phase means they run against a Postgres connection that the build container may not have access to. The correct pattern is a migrate.js script that runs prisma migrate deploy at the start of the startCommand before starting the HTTP server, or a Railway pre-deploy command if your plan supports it.

Three areas still warrant manual review regardless of CLAUDE.md quality.

Railway resource limits per service are project-specific policy. Claude cannot know whether your API service should be limited to 512MB RAM or 2GB. Set memory limits explicitly in the Railway dashboard after your first deploy and add them to CLAUDE.md so Claude does not generate code that assumes unlimited memory.

Custom domain configuration involves Railway's dashboard settings and your DNS provider. Claude generates the correct CNAME or A record target when asked, but the Railway dashboard steps for domain verification and SSL are environment-specific and cannot be scripted without Railway API access.

Secrets rotation for third-party API keys is an operational concern. When you rotate a Stripe or SendGrid API key, Claude cannot update Railway environment variables on your behalf unless you have Railway MCP configured with service write access.

Building for deployment from the start

The Railway CLAUDE.md in this guide produces generated config that separates build and start commands correctly, references Postgres and Redis through Railway's service reference syntax, routes inter-service calls through private networking, configures cron services with the right restart policy, and keeps secrets out of all committed files.

The underlying pattern is the same one across every deployment platform: Claude Code generates deployment-ready config when it has explicit rules for your platform's model. Railway's project and service model is specific enough that generic cloud deployment knowledge produces config that works on Heroku or Render but not Railway. CLAUDE.md is where you encode the Railway-specific decisions once.

For the foundational Claude Code workflow this Railway configuration builds on, Claude Code best practices covers session structure, how CLAUDE.md interacts with the agentic loop, and multi-file editing patterns. Claudify includes a Railway-specific CLAUDE.md template with the multi-service architecture rules, environment variable patterns, cron service config, and private networking conventions from this guide pre-configured.

More like this

Ready to upgrade your Claude Code setup?

Get Claudify
Featured on Dofollow.Tools AI Toolz Dir