Skip to content

Search the guides, deployment pages, and API reference.

Railway

Deploy a Remix 3 app to Railway 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.

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. The Railway service deploys that pre-built 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):

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
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 POSTs fall through to the index render). Wrap the built entry in a three-line Bun.serve server instead:

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
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
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. 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, and Railway pulls a private image only with registry credentials, 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 as a dev dependency:

npm
npm add -D @railway/cli
yarn
yarn add -D @railway/cli
pnpm
pnpm add -D @railway/cli
bun
bun add -D @railway/cli
deno
deno add -D npm:@railway/cli
vp
vp add -D @railway/cli
vlt
vlt add -D @railway/cli
nub
nub add -D @railway/cli

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

Terminal window
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:

.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() in SPA mode: server: false builds no server environment, so there is no server.ts and no runtime process. The plugin's remaining job is component HMR. vite build emits a static site; on Railway, a multi-stage Dockerfile builds it and serves it with Caddy.

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>
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 />);
tsconfig.json
{ "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "remix/component" } }
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:

npm
npm add -D pitlane
yarn
yarn add -D pitlane
pnpm
pnpm add -D pitlane
bun
bun add -D pitlane
deno
deno add -D npm:pitlane
vp
vp add -D pitlane
vlt
vlt add -D pitlane
nub
nub add -D pitlane

The Pitlane package guide 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
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 above.