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:
dockerfilePath: "Dockerfile"andstartCommand: "node server/entry.js"are both relative to the upload root, which isdist/. They are the two fields that break silently if you upload the project root instead.healthcheckPathis the scaffold's/api/healthroute. Railway requests it after the container starts and marks the deploy failed if it does not answer200withinhealthcheckTimeout(300 seconds by default).sleepApplication: falsekeeps Railway from stopping the container while it is idle. Hot routes hold WebSocket state in the process, so sleeping drops live connections. This is the Railway equivalent ofauto_stop_machines = "off"in thefly.tomlVura emits.numReplicas: 1because that state is per-process. The scaffold's chat room lives in one process; two replicas are two separate rooms.
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
railwayjob in.github/workflows/selfhost.ymlruns this guide's own blocks. It asserts thedockerfileblock above is byte-identical to thedist/Dockerfilevura buildemits, validates therailway.jsonblock and checks that every path it names resolves insidedist/(the check that catches the wrong-build-context mistake described at the top), builds the image withdist/as the context, then starts the container the way Railway starts it:startCommandoverriding the imageCMD, and an injectedPORTdifferent from the Dockerfile's default. It then requireshealthcheckPathto answer200, runs the full page-serving assertions every self-host job runs, and opens a WebSocket to/api/chatinside the container.What CI cannot cover, because it requires a real Railway account and none of it runs against Railway:
railway login,railway link, andrailway upthemselves, the upload and.gitignorefiltering, Railway's own builder and image registry, the generated*.railway.appdomain 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.