Railway
Deploy a Remix 3 app to Railway as a plain server process that runs straight from source. Nothing compiles the app ahead of time. The image installs dependencies and starts server.ts, and remix/assets compiles each browser module the first time a browser asks for it, the same way it does in development.
The Dockerfile is built in your CI, never by Railway. CI runs docker build and pushes the image to GitHub Container Registry, and installing dependencies is the only work inside that build. The Railway service deploys that pre-built image. Railway only pulls and runs. It injects a PORT environment variable at runtime, which the starter's server.ts already reads.
Add a .dockerignore. Leaving out node_modules matters more than usual here. The asset server compiles with native addons, and the image needs the binaries for its own platform rather than the ones installed for yours.
node_modules
.git
.env*Production assets
The starter's app/assets.ts already reads NODE_ENV: outside development it stops watching files and minifies what it serves. Add fingerprinting, which is what Remix recommends for a deployment:
export const assets = createAssetServer({
// ...
minify: !isDevelopment,
watch: isDevelopment,
fingerprint: !isDevelopment,
});Every asset URL then carries a content hash, /assets/app/actions/public/entry.@xnRkP3.ts, and is served with Cache-Control: public, max-age=31536000, immutable. Fingerprinting and file watching cannot be combined, so both follow the same flag.
An unset NODE_ENV counts as development, so each Dockerfile below sets it to production. The starter's start script runs that mode locally, under Node, before you build an image:
npm startNode
remix/node-tsx is the loader that lets Node run the app's .tsx files. It comes with remix and needs Node 24.3 or later. Production dependencies are everything the server needs, since nothing is compiled ahead of time:
# Dockerfile
FROM node:24-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
ENV NODE_ENV=production
CMD ["node", "--import", "remix/node-tsx", "server.ts"]The starter has no lockfile. Run npm install once to create the package-lock.json this Dockerfile installs from.
Bun
Bun runs TypeScript and JSX natively and takes the JSX settings from tsconfig.json, so it runs the starter's server.ts as it is, node:http server included:
# Dockerfile
FROM oven/bun:1-alpine
WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile --production
COPY . .
ENV NODE_ENV=production
CMD ["bun", "server.ts"]Run bun install once to create the bun.lock this Dockerfile installs from.
Bun does not resolve subpath imports that start with #/. The starter uses relative imports and is unaffected. If your app maps "#/*" in package.json, give the pattern a name, such as "#app/*".
Deno
Deno runs TypeScript and JSX natively and installs from package.json. It reads the JSX settings from tsconfig.json, so it too runs the starter's server.ts as it is:
# Dockerfile
FROM denoland/deno:debian
WORKDIR /app
COPY package.json deno.lock ./
RUN deno install --frozen
COPY . .
ENV NODE_ENV=production
CMD ["deno", "run", "-A", "server.ts"]Run deno install once to create the deno.lock this Dockerfile installs from.
Use the Debian image. On denoland/deno:alpine the install picks the musl build of the asset server's native addons, and Deno cannot load that build there, so the server exits before it starts listening.
The exec-form CMD is fine here because server.ts reads process.env.PORT itself. (-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 (installing dependencies 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 image the workflow pushes holds your source and its installed dependencies, nothing more.
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.