---
title: "Deploy to Railway (No Build)"
description: "Run a Remix 3 app on Railway under Node, Bun, or Deno straight from source: one Dockerfile that installs dependencies and starts server.ts, with no build step."
url: "https://pitlane.tools/deploy/railway-no-build"
buildMode: "no-build"
counterpart: "https://pitlane.tools/deploy/railway.md"
---

# Deploy to Railway (No Build)

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

Deploy a Remix 3 app to [Railway](https://railway.com) 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`](https://github.com/remix-run/remix/tree/main/packages/assets) compiles each browser module the first time a browser asks for it, the same way it does in development.

> [!TIP]
>
> **Start from the Remix starter**
>
> `npx remix@next new my-app` scaffolds an app with no build step. This guide deploys it with one change to its asset server, under Node, Bun, or Deno.

The Dockerfile is **built in your CI, never by Railway**. CI runs `docker build` and pushes the image to [GitHub Container Registry](https://ghcr.io), and installing dependencies is the only work inside that build. 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, 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:

```ts title="app/assets.ts"
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:

```sh
npm start
```

## Node

`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
# 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
# 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](https://nodejs.org/api/packages.html#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
# 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](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 (installing dependencies 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 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.

> [!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).
