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):
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:
// @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/cliyarn
yarn add -D @railway/clipnpm
pnpm add -D @railway/clibun
bun add -D @railway/clideno
deno add -D npm:@railway/clivp
vp add -D @railway/clivlt
vlt add -D @railway/clinub
nub add -D @railway/cliThen every deploy is a build, a push, and a railway redeploy that tells Railway to pull it.
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 --yesrailway 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:
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.
<!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>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 />);{ "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "remix/component" } }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 pitlaneyarn
yarn add -D pitlanepnpm
pnpm add -D pitlanebun
bun add -D pitlanedeno
deno add -D npm:pitlanevp
vp add -D pitlanevlt
vlt add -D pitlanenub
nub add -D pitlaneThe 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 /srvDeploys are unchanged: the same image flow (docker build + push + railway redeploy) from the CLI, or the same GitHub Actions workflow above.