---
title: "Deploy to Railway"
description: "Run a Remix 3 app on Railway under Node, Bun, or Deno: the built fetch handler plus one Dockerfile."
url: "https://pitlane.tools/deploy/railway"
buildMode: "vite"
counterpart: "https://pitlane.tools/deploy/railway-no-build.md"
---

# Deploy to Railway

This guide for: **Vite** · [No Build](https://pitlane.tools/deploy/railway-no-build.md)

Deploy a Remix 3 app to [Railway](https://railway.com) as a plain server process. No platform plugin is involved. `vite build` produces `dist/ssr/index.js` (your fetch handler) and `dist/client/` (static assets that the `staticFiles` middleware in your router serves), and a Dockerfile runs whichever runtime you choose.

> [!TIP]
>
> **Start from a template**
>
> `npx giget github:pitlane-tools/templates/railway-node my-app` (or `railway-bun`, `railway-deno`) scaffolds a working guest book app wired for this guide. See [pitlane-tools/templates](https://github.com/pitlane-tools/templates).

The Dockerfile is **built in your CI, never by Railway**: CI runs `docker build` (the Vite build happens inside it, under your control) and pushes the image to [GitHub Container Registry](https://ghcr.io). The Railway service [deploys that pre-built image](https://docs.railway.com/quick-start#deploying-your-project---from-a-docker-image). Railway only pulls and runs. It injects a `PORT` environment variable at runtime. Your server binds `0.0.0.0:$PORT`.

Add a `.dockerignore` so builds stay small and reproducible:

```
node_modules
dist
.git
.env*
```

## Node

The Node template's `server.ts` already reads `PORT` (Node runs TypeScript directly):

```ts title="server.ts"
import * as http from "node:http";
import { createRequestListener } from "remix/node-fetch-server";

// @ts-expect-error - built output has no types
import ssr from "./dist/ssr/index.js";

let server = http.createServer(createRequestListener(request => ssr.fetch(request)));
server.listen(process.env.PORT ? Number.parseInt(process.env.PORT, 10) : 3000);
```

```dockerfile
# Dockerfile
FROM node:24-alpine
WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci

COPY . .
RUN npm run build && npm prune --omit=dev

ENV NODE_ENV=production
CMD ["node", "server.ts"]
```

The exec-form `CMD` is fine here because `server.ts` reads `process.env.PORT` itself.

## Bun

Bun's automatic serving of default-exported `fetch` handlers doesn't apply cleanly here: `bun run` passes its `Server` object as a second argument and routes through its own server wrapper, which breaks the router's action dispatch (form `POST`s fall through to the index render). Wrap the built entry in a three-line `Bun.serve` server instead:

```ts title="server.ts"
// @ts-expect-error - built output has no types
import ssr from "./dist/ssr/index.js";

let server = Bun.serve({
    port: process.env.PORT ? Number.parseInt(process.env.PORT, 10) : 3000,
    fetch: request => ssr.fetch(request),
});
```

```dockerfile
# Dockerfile
FROM oven/bun:1-alpine
WORKDIR /app

COPY package.json bun.lock ./
RUN bun install --frozen-lockfile

COPY . .
RUN bun run build

ENV NODE_ENV=production
CMD ["bun", "server.ts"]
```

## Deno

The built entry matches `deno serve`'s default-export contract exactly. One flag matters: `deno serve` does not read `$PORT` itself, so pass it explicitly, and use the **shell-form `CMD`**, because exec-form (`CMD ["deno", …]`) never expands environment variables:

```dockerfile
# Dockerfile
FROM denoland/deno:alpine
WORKDIR /app

COPY . .
RUN deno install --allow-scripts
RUN deno task build

# Shell form so Railway's injected $PORT expands.
CMD deno serve -A --port=$PORT dist/ssr/index.js
```

(`-A` grants the runtime permissions the server code needs. Tighten to specific `--allow-*` flags once you know your app's surface.)

## Deploy with the CLI

One-time setup: create an empty project at [railway.com/new](https://railway.com/new). Add a service with **Docker Image** as the source (e.g. `ghcr.io/<user>/my-remix-app:latest`). Generate a domain under **Settings → Networking**, because services are private by default.

A new GHCR package is [private by default](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry), and Railway pulls a private image only with [registry credentials](https://docs.railway.com/builds/private-registries), which require the Pro plan. On any other plan, make the package public after the first push, from **Package settings → Change visibility** on the package's GitHub page. The change is permanent. A public image exposes every file `COPY . .` put in it, so the `.dockerignore` above leaves out `.env*`.

Install the [Railway CLI](https://docs.railway.com/cli) as a dev dependency:

```sh
npm add -D @railway/cli
```

With yarn, pnpm, bun, vp, vlt, or nub, run the same command with the manager's name in place of `npm`. With deno, put its name in place of `npm` and write each package as `npm:<package>`.

Then every deploy is a build, a push, and a `railway redeploy` that tells Railway to pull it.

```sh
docker build -t ghcr.io/<user>/my-remix-app:latest .
docker push ghcr.io/<user>/my-remix-app:latest

vpx railway login
vpx railway link            # link this directory to the project once
vpx railway redeploy --service my-remix-app --yes
```

`railway redeploy` re-pulls the image tag and rolls the service. The platform uploads no source and runs no build.

## Deploy with GitHub Actions

Create a project-scoped token (**Project Settings → Tokens**) and store it as the `RAILWAY_TOKEN` repository secret. The workflow builds the image (running the Vite build inside `docker build`, in CI), pushes it to GHCR with the built-in `GITHUB_TOKEN`, and redeploys the service:

```yaml title=".github/workflows/deploy.yml"
name: Deploy

on:
    push:
        branches: [main]

permissions:
    contents: read
    packages: write

jobs:
    deploy:
        runs-on: ubuntu-latest
        steps:
            - uses: actions/checkout@v4

            - uses: docker/login-action@v3
              with:
                  registry: ghcr.io
                  username: ${{ github.actor }}
                  password: ${{ secrets.GITHUB_TOKEN }}

            - uses: docker/build-push-action@v6
              with:
                  context: .
                  push: true
                  tags: ghcr.io/${{ github.repository }}:latest

            - uses: voidzero-dev/setup-vp@v1.21.1

            - run: vp install --frozen-lockfile

            - run: vpx railway redeploy --service my-remix-app --yes
              env:
                  RAILWAY_TOKEN: ${{ secrets.RAILWAY_TOKEN }}
```

`--service` names the Railway service to roll (`railway status` lists them). The Vite build point is the `docker build` step, which runs in CI under your control and never on the platform.

## Environment variables

Set them per service in the dashboard (**Variables**) or `railway variable set KEY=value`. Railway injects `PORT`. Define your own only if you need a fixed port. `railway run <cmd>` injects the service's variables into a local process for parity debugging.

## Client-only apps

A client-only Remix 3 app runs [`remix()`](https://pitlane.tools/package/vite-plugin-remix/function/remix.md) in [SPA mode](https://pitlane.tools/guides/spa.md): `server: false` builds no server environment, so there is no `server.ts` and no runtime process. The plugin's remaining job is [component HMR](https://pitlane.tools/guides/hmr.md#component-hmr). `vite build` emits a static site; on Railway, a multi-stage Dockerfile builds it and serves it with [Caddy](https://caddyserver.com).

```html title="index.html"
<!doctype html>
<html lang="en">
    <head>
        <meta charset="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <title>My Remix App</title>
        <script type="module" src="/app/main.tsx"></script>
    </head>
    <body>
        <div id="app"></div>
    </body>
</html>
```

```tsx title="app/main.tsx"
import { createRoot, on, type Handle } from "remix/component";

function App(handle: Handle) {
    let count = 0;

    return () => (
        <button
            mix={[
                on("click", () => {
                    count++;
                    handle.update();
                }),
            ]}
        >
            Count: {count}
        </button>
    );
}

createRoot(document.getElementById("app")!).render(<App />);
```

```jsonc title="tsconfig.json"
{ "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "remix/component" } }
```

```ts title="vite.config.ts"
import { remix } from "pitlane/vite-plugin-remix";
import { defineConfig } from "vite";

export default defineConfig({
    plugins: [remix({ server: false })],
});
```

`pitlane/vite-plugin-remix` comes from the `pitlane` package. The Railway templates depend on the scoped `@pitlane/vite-plugin-remix` package, so add `pitlane` before switching the import. Only `vite.config.ts` imports it, so a dev dependency is enough:

```sh
npm add -D pitlane
```

With yarn, pnpm, bun, vp, vlt, or nub, run the same command with the manager's name in place of `npm`. With deno, put its name in place of `npm` and write each package as `npm:<package>`.

The [Pitlane package guide](https://pitlane.tools/guides/umbrella.md) lists the `remix` and `vite` versions it requires.

The Caddyfile binds Railway's injected port and rewrites deep links to `index.html`:

```
# Caddyfile
:{$PORT}
root * /srv
encode gzip
try_files {path} /index.html
file_server
```

```dockerfile
# Dockerfile
FROM node:24-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM caddy:alpine
COPY Caddyfile /etc/caddy/Caddyfile
COPY --from=build /app/dist /srv
```

Deploys are unchanged: the same image flow (`docker build` + push + `railway redeploy`) from the CLI, or the same [GitHub Actions workflow](#deploy-with-github-actions) above.

> [!TIP]
>
> **Platform features**
>
> However the image arrives, the service gets automatic SSL, [custom domains](https://docs.railway.com/networking/domains), per-PR [preview environments](https://docs.railway.com/environments#pr-environments), and an optional [built-in CDN](https://docs.railway.com/networking/cdn).
