Documentation

Railway

What you'll have at the end: a Vura app running on Railway, built from the dist/ directory vura build emits, using the Dockerfile Vura generates, listening on the PORT Railway injects.

Supports all route kinds: serverless, hot (WebSocket), task.


Upload dist/, not the project root

Railway uses the root of the source you upload as the Docker build context. It does not move the context to whichever directory holds the Dockerfile; dockerfilePath is only the -f argument.

vura build writes dist/Dockerfile, and that file is written for a dist/ context. COPY . ./ means "copy the contents of dist/", and CMD ["node", "server/entry.js"] means dist/server/entry.js.

So the directory you hand Railway has to be dist/ itself. Upload the project root instead, with dockerfilePath pointed at dist/Dockerfile, and you get an image that builds successfully, reports no error, and then crash-loops on Cannot find module '/app/server/entry.js': relative to the project root, the entry is at dist/server/entry.js.

This is the same shape as fly deploy ./dist in the Fly guide.


Steps

1. Scaffold and build

npm create vura@latest my-app
cd my-app
npm install
npm run build

2. The Dockerfile

vura build emits dist/Dockerfile when the project has hot routes, which the default scaffold does. It is the same file the Docker guide uses, and CI asserts this block is byte-identical to what vura build writes:

# Generated by vura build — hand-edits will be overwritten on next build.
# Build context must be the dist/ directory:
#   docker build -f dist/Dockerfile dist
FROM node:22-slim
WORKDIR /app
COPY package.json ./
RUN npm install --omit=dev --no-audit --no-fund
COPY . ./
ENV NODE_ENV=production PORT=3000
EXPOSE 3000
CMD ["node", "server/entry.js"]

If your project has no hot routes, vura build does not emit it. Create dist/Dockerfile yourself with exactly the contents above.

3. Add dist/railway.json

{
  "$schema": "https://railway.com/railway.schema.json",
  "build": {
    "builder": "DOCKERFILE",
    "dockerfilePath": "Dockerfile"
  },
  "deploy": {
    "startCommand": "node server/entry.js",
    "healthcheckPath": "/api/health",
    "sleepApplication": false,
    "numReplicas": 1
  }
}

Field by field:

Put this file in dist/, not at the project root. Railway reads the config file from the root of the source you upload, and that root is dist/. vura build does not clear dist/, so the file survives later builds.

4. Deploy via Railway CLI

railway login
railway link    # link to an existing project, or create one in the Railway dashboard
cd dist
railway up

railway up uploads the current directory, so running it from inside dist/ makes dist/ the build context. railway up ./dist --path-as-root from the project root is equivalent.

The scaffold's .gitignore lists dist/, and railway up honours .gitignore by default. If Railway reports that the Dockerfile is missing, re-run as railway up --no-gitignore.

Push-to-deploy from a linked GitHub repo does not work with this Dockerfile. dist/ is a build artifact and is gitignored, so it is not in the repo for Railway to find. That flow needs a Dockerfile at the repo root that installs dependencies and runs vura build inside the image. It is not covered by this guide and is not CI-tested.

5. PORT note

Railway injects PORT as an environment variable at runtime and routes public traffic to it. dist/server/entry.js reads process.env.PORT at startup, and binds 0.0.0.0 because the Dockerfile sets NODE_ENV=production. The ENV PORT=3000 line is only a fallback for running the image locally; Railway's injected value takes precedence. No code change is needed.

6. Verify

curl -fsS https://your-app.railway.app/api/health

Smoke test

# Static page
curl -fsS https://your-app.railway.app/ | grep -q '<h1'

# API route
curl -fsS https://your-app.railway.app/api/hello

# WebSocket
wscat -c wss://your-app.railway.app/api/chat

CI-tested: the railway job in .github/workflows/selfhost.yml runs this guide's own blocks. It asserts the dockerfile block above is byte-identical to the dist/Dockerfile vura build emits, validates the railway.json block and checks that every path it names resolves inside dist/ (the check that catches the wrong-build-context mistake described at the top), builds the image with dist/ as the context, then starts the container the way Railway starts it: startCommand overriding the image CMD, and an injected PORT different from the Dockerfile's default. It then requires healthcheckPath to answer 200, runs the full page-serving assertions every self-host job runs, and opens a WebSocket to /api/chat inside the container.

What CI cannot cover, because it requires a real Railway account and none of it runs against Railway: railway login, railway link, and railway up themselves, the upload and .gitignore filtering, Railway's own builder and image registry, the generated *.railway.app domain and its TLS, and Railway's healthcheck and app-sleeping machinery. Everything CI verifies is the artifact and the runtime contract; the Railway control plane is taken on its documentation.


Route kind support

All kinds: serverless, hot (WebSocket), task / cron.