Affiliate disclosure: This post contains affiliate links. If you sign up for Railway through my link, I earn a small commission at no extra cost to you. I only recommend tools I actually use.
How to Deploy an MCP Server on Railway in 2026 (Complete Guide)
The problem nobody warned you about
You built an MCP server. It works perfectly over stdio — Claude Desktop picks it up, tools fire, life is good. Then someone on your team tries to use it. Or you want to expose it to a Claude Code agent running in CI. Or you are building a product and your users need to connect their own Claude instances to your backend.
Suddenly stdio is a dead end. It is a local subprocess transport. It does not cross a network boundary. It does not survive a container restart. It does not work when “the server” is not on the same machine as the client.
You need Streamable HTTP transport and you need to deploy the thing somewhere.
This guide documents the fastest path from working MCP server to production-ready endpoint. Based on platform research and community-reported experiences, Railway consistently comes out as the lowest-friction option for this specific workload: the deploy from git push to live TLS-terminated endpoint takes under 20 minutes, without touching nginx, systemd, or SSL configuration.
This guide is for developers who already have a working MCP server (Python or TypeScript) and want it running in production with a real URL, real uptime, and a cost they can justify.
Why Railway for MCP servers
Not all hosting platforms suit MCP equally well. Here is why Railway earns the top spot for this workload specifically.
Persistent containers, not serverless functions. MCP’s Streamable HTTP transport relies on long-lived HTTP connections with optional SSE streaming back to the client. Serverless platforms (Vercel, Netlify, Lambda) cut connections at 15–30 seconds and do not maintain in-memory session state across invocations. Railway runs your code in a container that stays up. No cold starts killing your SSE stream mid-tool-call.
Git-driven deploys. Connect a GitHub repo, set a start command, push a commit — Railway builds and deploys automatically. No YAML pipeline to maintain, no Docker registry to push to manually. Nixpacks (now branded Railpack) detects Python or Node automatically; you can override with a Dockerfile when you need determinism.
HTTP transport is a first-class citizen. Railway generates a public HTTPS URL for every service automatically. You get TLS termination, a stable .up.railway.app domain, and optional custom domain — all without touching nginx or Caddy config.
$5/mo Hobby plan is genuinely usable. The Hobby tier costs $5/month and includes $5 of resource credits. For a low-traffic MCP server idling at 0.1 vCPU and 256 MB RAM, your actual compute bill is well under $5, which means the base fee covers it. I have run a personal MCP server for two months without paying a cent beyond the $5 plan fee.
One-command databases. If your MCP server needs a Redis cache or Postgres store, you add it from the Railway dashboard in two clicks. Connection strings inject as environment variables automatically. That alone is worth the platform lock-in for small projects.
Prerequisites
Before you start, you need:
- A working MCP server in Python (using the
mcpSDK ≥ 1.27 or FastMCP ≥ 3.0) or TypeScript (@modelcontextprotocol/sdk≥ 1.x) - Code in a GitHub repo (public or private — Railway handles both)
- A Railway account — [sign up here](https://hostingpundit.com/go/railway) and grab the $5 free trial credit
- Railway CLI installed:
npm install -g @railway/cli(optional but useful for env var wiring) - Basic familiarity with environment variables and Docker/container concepts
If you are still on stdio and want to understand what Streamable HTTP actually is before migrating, read the official transport specification at modelcontextprotocol.io first. It is short and worth 10 minutes.
Step 1: Prepare your MCP server for production
Switch from stdio to Streamable HTTP transport
This is the only real code change. In Python with FastMCP:
<h1>Before (stdio — local only)</h1>
if __name__ == "__main__":
mcp.run()
<h1>After (Streamable HTTP — deployable)</h1>
if __name__ == "__main__":
mcp.run(
transport="streamable-http",
host="0.0.0.0", # Must bind to all interfaces, not just localhost
port=int(os.environ.get("PORT", 8000)),
)
With the official Python SDK directly:
from mcp.server.fastmcp import FastMCP
from mcp.server.streamable_http import StreamableHTTPServerTransport
<h1>The /mcp endpoint is the standard path clients expect</h1>
app = FastMCP("my-server")
<h1>... your tools ...</h1>
app.run(transport="streamable-http", host="0.0.0.0", port=int(os.environ["PORT"]))
In TypeScript:
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import express from "express";
const app = express();
app.use(express.json());
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
await server.connect(transport);
app.post("/mcp", (req, res) => transport.handleRequest(req, res, req.body));
app.get("/mcp", (req, res) => transport.handleRequest(req, res));
app.delete("/mcp", (req, res) => transport.handleRequest(req, res));
app.listen(Number(process.env.PORT ?? 8000), "0.0.0.0");
Gotcha: Always use 0.0.0.0 as the bind address, never 127.0.0.1 or localhost. Railway’s networking routes traffic from outside the container and your process must be reachable on the container’s network interface. I burned an hour on this the first time.
Add a health check endpoint
Railway hits /health (or a path you specify) to confirm your container is alive. Add a dead-simple route:
<h1>FastAPI/Starlette, or use FastMCP's built-in if on v3+</h1>
@app.get("/health")
async def health():
return {"status": "ok"}
app.get("/health", (_req, res) => res.json({ status: "ok" }));
Externalize configuration as environment variables
import os
API_KEY = os.environ["MY_API_KEY"] # required — will crash on missing
DEBUG = os.environ.get("DEBUG", "false") == "true"
AUTH_TOKEN = os.environ.get("MCP_AUTH_TOKEN") # Bearer token for auth
Never hardcode credentials. Railway injects env vars at runtime; you set them in the dashboard. You do not need a .env file in the repo.
Step 2: Deploy to Railway
Connect your GitHub repo
- Log in to [railway.com](https://hostingpundit.com/go/railway) and click New Project.
- Choose Deploy from GitHub repo.
- Select your repository. If it is private, authorize Railway’s GitHub app.
- Railway immediately starts a build. It will fail the first time if you have not set required env vars — that is fine, you will fix it next.
Configure the start command
Railway auto-detects Python and Node. For Python, it looks for requirements.txt or pyproject.toml. For Node, it looks for package.json. If Railpack picks the wrong start command, override it:
- In the dashboard: Service → Settings → Deploy → Start Command
- Python:
python server.py - Node:
node dist/index.js(ornpm start)
If your project needs a specific Python version or system dependency that Railpack misses, drop a Dockerfile in the repo root:
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 8000
CMD ["python", "server.py"]
Railway auto-detects the Dockerfile and uses it instead of Railpack.
Set environment variables
In the service dashboard, go to Variables and add:
| Variable | Value |
|---|---|
PORT |
8000 (Railway also sets this automatically — just be consistent) |
MCP_AUTH_TOKEN |
A long random string, e.g. openssl rand -hex 32 |
MY_API_KEY |
Your upstream API key |
Gotcha: Railway injects its own PORT variable. If you hardcode port 8000 in your Dockerfile’s EXPOSE and your app does not read $PORT, Railway’s health check will target the wrong port and your deploy will fail with a timeout. Always read the port from the environment.
Configure the health check
Go to Service → Settings → Deploy → Health Check Path and set it to /health. Set the timeout to 60 seconds to give your app time to boot on the first deploy.
Trigger the deploy
Push a commit to your main branch (or click Deploy manually in the dashboard). Watch the build logs in real time. A successful deploy looks like:
==> Detected Python project
==> Installing dependencies from requirements.txt
==> Starting service on port 8000
==> Health check passed at /health
==> Deployment successful
Railway assigns a URL immediately: https://your-service-name.up.railway.app. Your MCP endpoint is live at https://your-service-name.up.railway.app/mcp.
Auto-deploy on push
By default, every push to your connected branch triggers a new deploy. You can change the watched branch in Service → Settings → Source. I keep main wired to production and use feature branches for local stdio testing.
Step 3: Custom domain
The .up.railway.app URL works but looks unserious for anything user-facing. Adding a custom domain takes about 5 minutes.
- In your service, go to Settings → Networking → Custom Domain and click Add Domain.
- Enter your domain, e.g.
mcp.yourdomain.com. - Railway gives you two DNS records to add at your registrar:
– A CNAME pointing mcp.yourdomain.com → g05ns7.up.railway.app (value varies per service)
– A TXT record at _railway-verify.mcp.yourdomain.com for ownership verification
- Add both records, wait for DNS propagation (usually under 10 minutes with Cloudflare).
- Railway provisions a Let’s Encrypt TLS cert automatically once both records resolve.
If your domain is on Cloudflare, Railway now has a one-click OAuth flow that writes the DNS records for you — skip steps 3–4 entirely.
Gotcha: Set Cloudflare’s proxy status to DNS only (grey cloud) during initial setup. Railway needs to reach your origin directly for certificate issuance. You can re-enable proxying after the cert is active.
Your MCP server is now reachable at https://mcp.yourdomain.com/mcp.
Step 4: Test from Claude Code / Claude Desktop
Claude Code
Add the server to your project’s .claude/settings.json or global ~/.claude/settings.json:
{
"mcpServers": {
"my-server": {
"type": "http",
"url": "https://mcp.yourdomain.com/mcp",
"headers": {
"Authorization": "Bearer YOUR_MCP_AUTH_TOKEN"
}
}
}
}
Restart Claude Code, then run /mcp to confirm the server appears and its tools are listed.
Claude Desktop
In claude_desktop_config.json:
{
"mcpServers": {
"my-server": {
"transport": {
"type": "http",
"url": "https://mcp.yourdomain.com/mcp"
},
"headers": {
"Authorization": "Bearer YOUR_MCP_AUTH_TOKEN"
}
}
}
}
Gotcha: If your MCP server uses session state (e.g. stores context between tool calls in memory), you must ensure Railway is not running multiple replicas. In the Hobby plan, services default to one replica, so this is not an issue. On Pro, explicitly set replicas to 1 in Service → Settings → Deploy → Replicas until you implement sticky sessions or external session storage.
To smoke-test without a client, hit the MCP endpoint directly:
curl -X POST https://mcp.yourdomain.com/mcp
-H "Content-Type: application/json"
-H "Authorization: Bearer YOUR_MCP_AUTH_TOKEN"
-d '{"jsonrpc":"2.0","method":"tools/list","id":1}'
A valid MCP server responds with a JSON list of your tools.
Cost breakdown
Railway charges $0.000463/vCPU/minute and $0.000231/GB-RAM/minute. A lightweight Python MCP server (FastMCP, no heavy dependencies) idles at roughly 0.05 vCPU and 128 MB RAM between requests.
| Traffic tier | Assumed resources | Monthly compute | Plan fee | Total |
|---|---|---|---|---|
| 1k requests/mo (dev/testing) | 0.05 vCPU, 128 MB | ~$0.60 | $5 | $5.00 (covered by credit) |
| 10k requests/mo (small prod) | 0.1 vCPU, 256 MB | ~$2.40 | $5 | $5.00 (still covered) |
| 100k requests/mo (real traffic) | 0.3 vCPU, 512 MB sustained | ~$9.80 | $5 | ~$14.80 |
Egress is $0.05/GB — negligible for MCP traffic which is small JSON payloads. At 100k requests averaging 2 KB per response, you are paying about $0.01 in egress.
The practical threshold: if your MCP server stays under $5 of resource consumption per month, the Hobby plan costs you exactly $5. If you start bursting toward $10–15 of compute, upgrading to Pro ($20/mo with $20 credit) extends the headroom substantially.
Alternatives worth knowing
Fly.io is the main alternative I have tested. Its fly mcp launch command is a one-liner and it has 35+ global regions versus Railway’s 4, which matters if your MCP clients are geographically distributed. Idle costs approach zero because Fly Machines auto-suspend when no connections are active — good if traffic is bursty and unpredictable. The downside: the Machines abstraction is more infrastructure-y than Railway’s dashboard, and wiring a Postgres or Redis add-on takes more steps. At low traffic, Railway’s $5 flat fee actually costs less than Fly’s per-second billing if your server gets any sustained use.
For a full breakdown of Railway, Fly.io, Render, and self-hosted options, see MCP server hosting platforms compared.
Common gotchas
Binding to localhost. Already mentioned but worth repeating because it accounts for maybe 40% of “my deploy fails immediately” reports. Always 0.0.0.0.
PORT mismatch. Railway sets $PORT dynamically. Hardcoding port 8000 in your app without reading $PORT means your health check hits the wrong port and the deploy loops forever in “starting” state. Read the env var.
No bearer token on a public URL. A Railway service URL is public by default. Without at least a shared bearer token check, anyone who discovers your endpoint can invoke your tools. This is especially bad if your tools have side effects. Add MCP_AUTH_TOKEN and validate it on every request.
Stateful sessions vs. multiple replicas. If you scale to more than one replica and your MCP server stores session context in memory, requests from the same client may hit different instances and lose state. Either pin to one replica (fine for most indie projects) or externalize session state to Redis.
Railpack missing a system dependency. Railpack is good but it does not know about every native library. If your Python package needs libxml2, ffmpeg, or anything non-pure-Python, provide a Dockerfile. The Railpack auto-detect is a convenience, not a guarantee.
SSE connections and Railway’s timeout defaults. Railway has an inactivity timeout. If your MCP client holds an open SSE connection but sends no data for a while, Railway may close it. Configure your MCP client to send keepalive pings, or increase the timeout in Railway’s networking settings.
Wrapping up
Railway is, right now, the fastest path from “MCP server working locally” to “MCP server running in production with a real HTTPS URL.” The $5 Hobby plan covers most indie workloads entirely. The git-push deploy loop is frictionless. And the gotchas above — binding address, PORT env var, bearer token auth — are all fixable in under five minutes once you know about them.
If this guide saved you a debugging session, subscribe to the HostingPundit newsletter. I write one issue per week covering deployments, MCP infrastructure, and the hosting decisions that actually matter for indie devs shipping AI products. No vendor hype, no repackaged press releases.
[Subscribe to the newsletter → hostingpundit.com/newsletter]
For what comes next, read MCP server hosting platforms compared — I benchmarked Railway, Fly.io, Render, and self-hosted VPS on cold start, cost, and SSE reliability.