Deploying to Railway — Step-by-Step Guide

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

0. One-time setup (every app type)

How a deploy reaches Railway

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).

Step 1 — Install the CLI

npm install -g @railway/cli
railway --version

Windows: railway: command not found right 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.

Step 2 — Sign in

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

Step 4 — Create a service, deploy it, give it a URL

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.

The commands you will use every day

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)

The one rule every app must follow

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.

1. .NET Core Web API

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.

Before you deploy (both options)

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.

Option A — single project, built by Railpack

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

Option B — solution with several projects, built from a Dockerfile

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.

Adding a PostgreSQL database

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.

Other settings the API usually needs

# 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();

Check it worked

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

2. React single-page app

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.

Before you deploy

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.

Configuration is baked in at build time

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:

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`);

Deploy

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.

Check it worked

Alternative: serve the built React app from the .NET API's wwwroot in 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.

3. Node.js app

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.

Before you deploy

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'));

Deploy

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.

A working example

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.

4. Plain HTML website

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.

Before you deploy

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://).

Optional: a Staticfile

Add 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.

Deploy

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).

Check it worked

Open the URL and click every link. A broken image or stylesheet is almost always a wrong-case file name or an absolute path.

5. After the first deploy

Updating

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.

Rolling back

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.

Custom domain

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.

Secrets

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 retired

Build 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.

6. Deploying from an Azure DevOps pipeline

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.

7. Troubleshooting

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