Guide · Railway

Deploy an MCP server on Railway

Railway auto-detects Node.js projects, manages environment variables through a UI, and provisions services like Redis and Postgres with a click. Getting an MCP server onto Railway takes three specific adjustments that differ from a standard web API: binding to process.env.PORT, switching to HTTP/SSE transport (stdio doesn't work in Railway's network model), and configuring a health check path so Railway knows when the server is genuinely ready to accept MCP connections.

TL;DR

Create a railway.json or let nixpacks detect your Node.js project. Set the start command to node dist/index.js (or whatever builds your server). Bind your HTTP server to process.env.PORT — Railway assigns a port and won't route traffic to anything else. Configure a /healthz endpoint that runs the MCP initialize handshake, then set the health check path in Railway's service settings. Use Railway volumes for any SQLite state. Add your server's public Railway domain to AliveMCP to get external protocol probing — Railway's own health checks only verify HTTP 200, not whether the MCP layer is functioning.

Transport: why HTTP/SSE is the only option

Stdio transport works by forking the server as a subprocess and piping messages through stdin/stdout. Railway doesn't expose subprocess pipes — your server runs as a container behind Railway's networking layer. The only way a client reaches your server is over HTTP, so HTTP/SSE is the transport to use.

If your server currently uses stdio (common in local dev), the switch to HTTP/SSE is a one-line change in most MCP SDKs:

// Before (stdio — local dev only)
const transport = new StdioServerTransport();

// After (HTTP/SSE — works on Railway)
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
const app = express();
const transports: Record<string, SSEServerTransport> = {};

app.get('/sse', (req, res) => {
  const transport = new SSEServerTransport('/messages', res);
  transports[transport.sessionId] = transport;
  server.connect(transport);
});

app.post('/messages', (req, res) => {
  const sessionId = req.query.sessionId as string;
  transports[sessionId]?.handlePostMessage(req, res);
});

app.listen(process.env.PORT || 3000);

See MCP server deployment — transport selection for a full comparison of transport options across deployment targets.

Binding to process.env.PORT

Railway dynamically assigns a port to each service and exposes it as PORT in the environment. If your server binds to a hardcoded port (e.g., 3000), Railway won't route external traffic to it — the service will appear to deploy successfully but be unreachable.

const PORT = parseInt(process.env.PORT || '3000', 10);
app.listen(PORT, '0.0.0.0', () => {
  console.log(`MCP server listening on port ${PORT}`);
});

The '0.0.0.0' bind address is important — binding to localhost or 127.0.0.1 makes the server unreachable from Railway's proxy.

railway.json configuration

Create a railway.json at the project root to explicitly set the build and start commands. Without it, nixpacks infers commands from package.json scripts, which works for most projects but can surprise you if your build step compiles TypeScript:

{
  "$schema": "https://railway.app/railway.schema.json",
  "build": {
    "builder": "NIXPACKS",
    "buildCommand": "npm ci && npm run build"
  },
  "deploy": {
    "startCommand": "node dist/index.js",
    "healthcheckPath": "/healthz",
    "healthcheckTimeout": 30,
    "restartPolicyType": "ON_FAILURE",
    "restartPolicyMaxRetries": 3
  }
}

The healthcheckPath tells Railway where to probe during deploys. Railway waits for an HTTP 200 at that path before marking the deploy as successful and routing traffic to the new instance. Without this, Railway routes traffic as soon as the process starts — before the MCP layer is initialized.

Health check endpoint

Railway's health check sends a plain HTTP GET and expects 200. But an HTTP 200 from a broken MCP layer is a silent failure — your server is up but can't actually serve MCP connections. Implement the health check endpoint to run the full initialize handshake against itself:

app.get('/healthz', async (req, res) => {
  try {
    const resp = await fetch(`http://localhost:${process.env.PORT}/sse`, {
      signal: AbortSignal.timeout(5000)
    });
    // SSE endpoint should return 200 with text/event-stream
    if (!resp.ok) throw new Error(`SSE endpoint returned ${resp.status}`);
    // Optionally run the full initialize handshake via a separate HTTP probe
    res.status(200).json({ status: 'ok', transport: 'sse' });
  } catch (err) {
    res.status(503).json({ status: 'unhealthy', error: (err as Error).message });
  }
});

For a deeper probe that validates the full initializetools/list sequence, see MCP server health checks. Railway marks the service unhealthy and retries if the healthcheck returns non-200, eventually triggering a rollback to the previous deploy if the new one can't pass health checks within the timeout window.

Environment variables

Set environment variables in the Railway service dashboard under "Variables". Railway injects them at runtime — never hardcode secrets in your repository or Dockerfile.

For variables shared across services (e.g., a database URL used by both an MCP server and an API service), Railway's "shared variable" feature lets you define a variable once at the project level and reference it as ${{shared.DATABASE_URL}} in each service's variable config.

Reference Railway-provided connection strings for provisioned services using their template syntax:

# In Railway service variables:
DATABASE_URL=${{Postgres.DATABASE_URL}}
REDIS_URL=${{Redis.REDIS_URL}}
NODE_ENV=production
LOG_LEVEL=info

These interpolations resolve at deploy time. Your code reads them as normal environment variables — no Railway SDK needed in your application code.

Persistent volumes for SQLite state

Railway containers have ephemeral filesystems — any file written inside the container is lost on restart or redeploy. If your MCP server uses SQLite to persist session state, tool call history, or any other data, mount a Railway volume:

In the Railway service dashboard, go to "Volumes" and add a volume mounted at /data (or wherever your SQLite file lives). Then point your database connection to that path:

// Use the volume mount path for SQLite
const DB_PATH = process.env.DB_PATH || '/data/mcp-server.db';

import Database from 'better-sqlite3';
const db = new Database(DB_PATH, { verbose: console.log });
db.pragma('journal_mode = WAL');
db.pragma('synchronous = NORMAL');

Set DB_PATH=/data/mcp-server.db as an environment variable in Railway to match the volume mount. Railway volumes persist across restarts and redeployments within the same service. They don't persist across service deletions.

Private networking between services

When your MCP server needs to connect to a Redis or Postgres service you provisioned in Railway, use Railway's private network hostnames rather than the public URLs. Private network traffic stays within Railway's network (faster, no egress cost, not exposed to the public internet):

# Private network URL (only reachable within the Railway project)
REDIS_URL=redis://Redis.railway.internal:6379

# Public URL (works from anywhere, but slower and incurs egress)
REDIS_URL=rediss://your-instance.railway.app:6379

Railway private network hostnames follow the pattern <SERVICE_NAME>.railway.internal. Use private networking for all service-to-service communication, and only expose the MCP server itself publicly via its Railway domain or a custom domain.

External monitoring beyond Railway's health checks

Railway's built-in health checks verify that your process is running and responding with HTTP 200. They don't verify that the MCP protocol layer is functioning, that your TLS certificate is valid from the client's perspective, or that DNS resolution is working. These are exactly the failure modes that cause your MCP server to be unreachable despite Railway showing it as "healthy".

Add your Railway service URL (https://your-service.up.railway.app) to AliveMCP. AliveMCP probes from outside Railway's network, running the full initializetools/list sequence over HTTPS, and alerts you when the protocol layer fails — not just when the process dies. See MCP server observability for how to combine Railway's internal metrics with external probing.

Related questions

Does Railway support WebSocket transport for MCP?

Railway's HTTP proxy supports WebSocket upgrades. The MCP spec allows WebSocket as a transport, and the @modelcontextprotocol/sdk WebSocket transport works on Railway as long as your server handles the Upgrade: websocket header. However, SSE (HTTP/2 server-sent events) is the more commonly used transport for deployed MCP servers because it works through all standard HTTP infrastructure without requiring WebSocket-specific proxy configuration.

How do I handle Railway's automatic restarts?

Railway restarts your service on crash, OOM, or failed health checks. Implement graceful shutdown: listen for SIGTERM, stop accepting new connections, wait for active sessions to complete (with a timeout), then exit cleanly. Set terminationGracePeriod in your railway.json to give active sessions time to drain. See MCP server Docker signal handling for the SIGTERM handler pattern — it applies equally to Railway deployments.

Can I deploy multiple MCP servers in one Railway project?

Yes. Each service in a Railway project is an independent deployment with its own domain, environment variables, and health checks. Use Railway's private networking (service-name.railway.internal) for services that need to call each other. Each service gets its own PORT assignment, and Railway's public proxy routes external traffic to each service on port 443. This is the right pattern for deploying multiple specialized MCP servers (one per domain, or a router + worker model).

What's the cheapest way to run an MCP server on Railway?

Railway's Hobby plan ($5/month) includes $5 of usage credits. A small MCP server (512 MB RAM, 0.5 vCPU) uses roughly $5–8/month at 100% utilization. For a lightly loaded server, costs are lower — Railway bills by actual resource usage, not reservation. Use Railway's sleep-on-inactivity feature for development or low-traffic environments: the service pauses when idle and wakes on the next request (adds ~3–5 second cold start to the first request after sleep).

Further reading