2026-09-18 · Railway CLI 5.54
How to put four kinds of app on Railway: a .NET Core Web API, a React single-page app, a Node.js app, and a plain HTML website. Do the one-time setup in section 0 once, then jump to the section for your app.
| I want to deploy… | Go to | Railway builds it with | What you must get right |
|---|---|---|---|
| A .NET Core Web API | Section 1 | Railpack .NET, or your Dockerfile | .csproj at the folder root, or a Dockerfile for multi-project solutions |
| A React single-page app | Section 2 | Railpack Node → served by Caddy | No start script; VITE_* variables set before the build |
| A Node.js app (Express, Fastify, …) | Section 3 | Railpack Node | Listen on process.env.PORT |
| A plain HTML website | Section 4 | Railpack static site → served by Caddy | index.html at the folder root, no package.json |
flowchart LR code[Your folder] --> cli["railway up (CLI or pipeline)"] gh[GitHub push] --> build cli --> build["Build: Dockerfile or Railpack"] build --> run["Start + healthcheck"] run --> url["Public URL via railway domain"]
Railway can auto-deploy on push only from GitHub. Our repositories live in Azure DevOps, which Railway does not connect to — so a git push deploys nothing. Deploy with the CLI (railway up) from your machine, or from an Azure Pipeline (section 6).
npm install -g @railway/cli
railway --version
Windows:
railway: command not foundright after installing? npm puts global tools in%APPDATA%\npm, and that folder is sometimes missing from PATH. Add it to your user PATH (System Properties → Environment Variables), then open a new terminal — terminals and VS Code that were already open keep the old PATH until restarted.
railway login # opens the browser
railway whoami # confirms who you are signed in as
On a machine with no browser (SSH, remote box), use railway login --browserless and open the printed link on any device.
A project holds one or more services (your API, your web app, a database). Run these from the folder you are deploying:
# New project (also links this folder to it)
railway init --name my-app
# …or attach this folder to a project that already exists
railway link --project my-app --environment production
railway add --service api # an empty service named "api"
railway up --service api # upload this folder, build, deploy
railway domain --service api # generate https://api-production-xxxx.up.railway.app
railway up uploads the current folder and respects .gitignore, so node_modules, bin and obj are not sent. Every later deploy is just railway up --service <name> again.
| Task | Command |
|---|---|
| Deploy the current folder | railway up --service <name> |
| Deploy and return immediately | railway up --service <name> --detach |
| Deploy one folder of a monorepo | railway up ./apps/api --path-as-root --service api |
| Watch build logs / runtime logs | railway logs --build / railway logs |
| Set a variable (triggers a redeploy) | railway variable set KEY=value --service <name> |
| List variables | railway variable list --service <name> |
| Show or create the public URL | railway domain list / railway domain --service <name> |
| Redeploy without uploading | railway redeploy --service <name> |
| Add a database | railway add --database postgres (also mysql, redis, mongo) |
Railway tells your app which port to use in the PORT environment variable and routes your public URL to it. An app that listens on a hard-coded port, or only on localhost, deploys "successfully" and then answers every request with an error. Each section below shows how to honour PORT for that stack — for .NET, React and plain HTML, Railway does it for you.
Railway builds .NET two ways. Pick by where your .csproj is:
| Your repository | Use |
|---|---|
The API's .csproj is at the root of the folder you deploy |
Option A — Railpack. Nothing to write. |
A solution with several projects (src/MyApp.Api, src/MyApp.Domain, …) |
Option B — Dockerfile. Railpack only looks for a .csproj at the root. |
1. Don't hard-code the listen address. Remove any builder.WebHost.UseUrls(...) and any Kestrel:Endpoints section in appsettings.json. Railway supplies the address (below).
2. Add a health endpoint so Railway only switches traffic to a deployment that actually started:
builder.Services.AddHealthChecks();
// …
app.MapHealthChecks("/health");
Then set Service → Settings → Deploy → Healthcheck Path to /health.
3. Remember HTTPS is handled by Railway. Railway terminates TLS at its edge and forwards plain HTTP to your app. app.UseHttpsRedirection() is harmless (it logs a warning and does nothing); don't add certificates to Kestrel.
4. Swagger. The environment is Production by default, so a Swagger UI guarded by IsDevelopment() will not appear. Leave it that way for anything real.
Railpack detects the .csproj, uses the .NET version from its TargetFramework (e.g. net8.0), runs dotnet publish -c Release, and sets ASPNETCORE_URLS=http://0.0.0.0:$PORT for you.
cd src/MyApp.Api # the folder containing MyApp.Api.csproj
railway init --name my-app
railway add --service api
railway up --service api
railway domain --service api
Put this Dockerfile at the solution root (capital D, no extension). Railway uses a root Dockerfile automatically instead of Railpack — the build log says "Using detected Dockerfile!".
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY . .
RUN dotnet publish src/MyApp.Api/MyApp.Api.csproj -c Release -o /app/publish
FROM mcr.microsoft.com/dotnet/aspnet:8.0
WORKDIR /app
COPY --from=build /app/publish .
# Listen on Railway's PORT (8080 when run locally)
CMD ["sh", "-c", "ASPNETCORE_URLS=http://0.0.0.0:${PORT:-8080} exec dotnet MyApp.Api.dll"]
And a .dockerignore next to it, so local build output is not copied in:
**/bin/
**/obj/
.git/
**/node_modules/
Change MyApp.Api to your project name, and 8.0 to 9.0/10.0 if you target a newer .NET. Test it locally first — if docker build fails on your machine, it will fail on Railway:
docker build -t myapp-api .
docker run -p 8080:8080 myapp-api # then open http://localhost:8080/health
Deploy from the solution root:
railway init --name my-app
railway add --service api
railway up --service api
railway domain --service api
If the Dockerfile is not at the root, point Railway to it with a variable: railway variable set RAILWAY_DOCKERFILE_PATH=build/Dockerfile --service api.
railway add --database postgres
The database service (named Postgres by default) exposes PGHOST, PGPORT, PGUSER, PGPASSWORD and PGDATABASE on the project's private network. Npgsql wants key-value format, so build the connection string from them with reference variables. A double underscore maps to a nested config key, so this fills ConnectionStrings:Default:
railway variable set 'ConnectionStrings__Default=Host=${{Postgres.PGHOST}};Port=${{Postgres.PGPORT}};Database=${{Postgres.PGDATABASE}};Username=${{Postgres.PGUSER}};Password=${{Postgres.PGPASSWORD}}' --service api
Keep the single quotes — they stop your shell from touching the ${{…}}, which Railway resolves itself. If you renamed the database service, use its name instead of Postgres.
Never commit the connection string. Locally it stays in dotnet user-secrets; on Railway it lives only in the service variables.
# Let the React app call this API (comma-separate several origins)
railway variable set 'Cors__Origins=https://web-production-xxxx.up.railway.app' --service api
var origins = builder.Configuration["Cors:Origins"]?.Split(',') ?? [];
builder.Services.AddCors(o => o.AddDefaultPolicy(p =>
p.WithOrigins(origins).AllowAnyHeader().AllowAnyMethod()));
// …
app.UseCors();
railway logs --build # did the build succeed?
railway logs # did the app start? look for "Now listening on: http://0.0.0.0:…"
curl https://api-production-xxxx.up.railway.app/health # expect: Healthy
For a React app built with Vite (or Create React App), Railpack runs npm run build and serves the output folder with the Caddy web server. It handles PORT, compression, and — most importantly — sends unknown paths back to index.html, so refreshing /customers/42 works. You do not write a server.
1. Do not add a start script to package.json. This is the one that catches people. If package.json has a start script (for example "start": "vite preview"), Railpack assumes you want to run your own server and turns SPA mode off. The default Vite scripts (dev, build, preview) are exactly right. Create React App's default "start": "react-scripts start" is recognised and allowed.
If you must keep a start script, force SPA mode back on by naming the build folder:
railway variable set RAILPACK_SPA_OUTPUT_DIR=dist --service web
2. Build output. Vite writes to dist, which is the default. If your build writes somewhere else, set RAILPACK_SPA_OUTPUT_DIR to that folder.
3. Check the build passes locally — the same command runs on Railway:
npm ci
npm run build
4. Commit your lock file (package-lock.json, pnpm-lock.yaml or yarn.lock). Railpack picks the package manager from it.
import.meta.env.VITE_API_URL is replaced with a fixed value when the app is built. A React app has no server-side environment at runtime. So:
VITE_* variables on the service before deploying.railway variable set triggers a redeploy automatically, which rebuilds.VITE_* variable is visible to every visitor. Never put secrets there.railway variable set VITE_API_URL=https://api-production-xxxx.up.railway.app --service web
const res = await fetch(`${import.meta.env.VITE_API_URL}/api/customers`);
From the React app's folder (the one containing package.json):
railway link --project my-app --environment production # same project as the API
railway add --service web
railway variable set VITE_API_URL=https://api-production-xxxx.up.railway.app --service web --skip-deploys
railway up --service web
railway domain --service web
Then add the web URL that railway domain printed to the API's Cors__Origins (section 1).
Set the healthcheck path to /health — Railpack's Caddy server answers it with 200 automatically.
localhost or undefined. If they don't, the VITE_* variable was set after the build: set it and redeploy.Alternative: serve the built React app from the .NET API's
wwwrootin one Docker image (build with Node in one stage, publish .NET in another). That is one service instead of two, one URL, and no CORS — worth it when the SPA and API always release together.
For a server you write yourself — Express, Fastify, NestJS, a worker. Railpack installs dependencies, runs your build script if there is one, and starts the app with your start script.
1. Listen on PORT, on all interfaces:
const PORT = process.env.PORT || 3000;
app.listen(PORT, '0.0.0.0', () => console.log(`listening on :${PORT}`));
localhost or 127.0.0.1 only accepts connections from inside the container, so Railway can't reach it.
2. Define start (and build if you compile), and pin the Node version:
{
"scripts": {
"build": "tsc",
"start": "node dist/index.js"
},
"engines": {
"node": "22.x"
}
}
Railpack reads the Node version from engines.node (or .nvmrc), and defaults to the current LTS. Plain JavaScript with no build step just needs "start": "node server.js".
3. Commit the lock file, and add a health route:
app.get('/health', (req, res) => res.send('ok'));
railway init --name my-node-app
railway add --service app
railway variable set NODE_ENV=production --service app --skip-deploys
railway up --service app
railway domain --service app
Set Healthcheck Path to /health.
This POC site is a Node app deployed exactly this way. server.js listens on process.env.PORT and 0.0.0.0, package.json has "start": "node server.js" and "engines": { "node": ">=20" }, and every update ships with railway up.
A folder of .html, .css, .js and images, with no build step. Railpack detects it and serves it with Caddy — you don't need Node, nginx or a Dockerfile.
1. index.html must be at the root of the folder you deploy. Railpack also treats a folder as a static site if it has a public directory or a Staticfile.
2. Don't add a package.json. If one is present, Railway builds the folder as a Node app instead (section 3).
3. File names are case-sensitive on Railway. Windows forgives <img src="Logo.PNG"> when the file is logo.png; Railway's Linux server returns 404. Match the case exactly.
4. Use relative links (css/site.css, not C:\site\css\site.css, and not file://).
StaticfileAdd a file named Staticfile (no extension) at the root when the site lives in a subfolder or needs page fallback:
root: site
index_fallback: true
root is the folder to serve. index_fallback: true sends unknown paths to index.html, which you only want for a single-page app.
cd my-site # the folder containing index.html
railway init --name my-site
railway add --service web
railway up --service web
railway domain --service web
Healthcheck path: /health (Caddy answers it automatically).
Open the URL and click every link. A broken image or stylesheet is almost always a wrong-case file name or an absolute path.
Change your code, then run railway up --service <name> from the same folder. Each run is a new deployment. Railway keeps the previous one serving traffic until the new one passes its healthcheck.
Open the service's Deployments list in the dashboard, find the last good deployment, open its ⋮ menu and choose Rollback. Know where this button is before you deploy something risky.
railway domain app.example.com --service web
The command prints the DNS records to add at your DNS provider. Railway issues the HTTPS certificate once DNS resolves.
Keep secrets in service variables (railway variable set), never in the repository or in appsettings*.json. Variables are per service and per environment.
railway.json is being retiredBuild and deploy settings can also be kept in a railway.json file in the repository. The CLI now warns that this format (Config as Code) is deprecated. Existing files keep working until 2026-12-01. After that, use Infrastructure as Code (.railway/railway.ts). Convert with:
railway config migrate
Settings made in the dashboard (Service → Settings) are unaffected.
Because Railway can't watch Azure DevOps, a pipeline runs the same railway up you would run by hand.
1. Create a project token: Railway dashboard → your project → Settings → Tokens → create a token for the production environment.
2. Store it in the pipeline as a secret variable named RAILWAY_TOKEN.
3. Add a pipeline (azure-pipelines.yml):
trigger:
branches:
include: [ main ]
pool:
vmImage: ubuntu-latest
steps:
- task: NodeTool@0
inputs:
versionSpec: '22.x'
- script: npm install -g @railway/[email protected]
displayName: Install Railway CLI
- script: railway up --ci --service api
displayName: Deploy to Railway
env:
RAILWAY_TOKEN: $(RAILWAY_TOKEN) # secret variables must be mapped explicitly
--ci streams the build log into the pipeline and fails the step if the build fails. For a monorepo, add one deploy step per service, e.g. railway up ./src/web --path-as-root --service web --ci.
| Symptom | Cause | Fix |
|---|---|---|
railway: command not found after installing |
npm's global folder is not on PATH (Windows) | Add %APPDATA%\npm to PATH, open a new terminal |
Unauthorized. Please login |
Not signed in, or no token in CI | railway login; in CI set RAILWAY_TOKEN |
| Deploy succeeds but the URL shows Application failed to respond | App listens on a fixed port or on localhost |
Listen on PORT and 0.0.0.0 (section 3); in .NET remove UseUrls / Kestrel endpoints |
| .NET project built as the wrong thing, or "no start command" | No .csproj at the root |
Deploy from the project folder, or use a Dockerfile (section 1, option B) |
| React: refreshing a deep link gives 404 | A start script turned SPA mode off |
Remove start, or set RAILPACK_SPA_OUTPUT_DIR=dist |
React: API calls go to undefined or localhost |
VITE_* variable set after the build |
Set it, then redeploy |
| Browser shows a CORS error | API doesn't allow the SPA's origin | Add the SPA URL to the API's Cors__Origins |
| Images or CSS 404 on Railway but work locally | File-name case differs; Linux is case-sensitive | Match the case exactly |
| Deployment never goes live, "healthcheck failed" | Wrong healthcheck path, or the app crashed on start | Check railway logs; fix the path in Settings → Deploy |
| Pushed to Azure DevOps but nothing deployed | Railway doesn't watch Azure DevOps | railway up, or add the pipeline in section 6 |
| CLI warns Config as Code is deprecated | railway.json in the repository |
railway config migrate before 2026-12-01 |