# 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

```mermaid
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

```bash
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

```bash
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.

### Step 3 — Create or link a project

A **project** holds one or more **services** (your API, your web app, a database). Run these from the folder you are deploying:

```bash
# 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

```bash
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:

```csharp
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.

```bash
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!"*.

```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:

```bash
docker build -t myapp-api .
docker run -p 8080:8080 myapp-api       # then open http://localhost:8080/health
```

Deploy from the solution root:

```bash
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

```bash
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`:

```bash
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

```bash
# 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
```

```csharp
var origins = builder.Configuration["Cors:Origins"]?.Split(',') ?? [];
builder.Services.AddCors(o => o.AddDefaultPolicy(p =>
    p.WithOrigins(origins).AllowAnyHeader().AllowAnyMethod()));
// …
app.UseCors();
```

### Check it worked

```bash
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:

```bash
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:

```bash
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:

- Set `VITE_*` variables on the service **before** deploying.
- Changing one means a **rebuild**. `railway variable set` triggers a redeploy automatically, which rebuilds.
- Anything in a `VITE_*` variable is visible to every visitor. Never put secrets there.

```bash
railway variable set VITE_API_URL=https://api-production-xxxx.up.railway.app --service web
```

```ts
const res = await fetch(`${import.meta.env.VITE_API_URL}/api/customers`);
```

### Deploy

From the React app's folder (the one containing `package.json`):

```bash
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

- Open the URL, click through to a deep page, and **press refresh**. A 404 there means SPA mode is off — see step 1.
- Open the browser dev tools → Network. API calls should go to your API's Railway URL, not `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 `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:**

```js
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:**

```json
{
  "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:

```js
app.get('/health', (req, res) => res.send('ok'));
```

### Deploy

```bash
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

```bash
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

```bash
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:

```bash
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`):

```yaml
trigger:
  branches:
    include: [ main ]

pool:
  vmImage: ubuntu-latest

steps:
  - task: NodeTool@0
    inputs:
      versionSpec: '22.x'

  - script: npm install -g @railway/cli@5.54.0
    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 |
