How to Deploy Meteor.js on Railway
A complete guide to getting a Meteor app running on Railway, using Meteor 3.5, the geoffreybooth/meteor-base image (from the disney/meteor-base repo), and a standalone MongoDB on Railway itself.
Just want it running? Deploy the one-click template and skip straight to a working Meteor + MongoDB stack on Railway.
TL;DR: the fast path
For those who just want it running. Every item has a full step below with the reasoning and the failure modes.
# Meteor app (needs Node 24+)
npx meteor
meteor create meteor-railway-test --release 3.5
cd meteor-railway-test
##############################################################
## STOP: copy the Dockerfile from step 2 into the root. ##
## Without it, Railway has nothing to build. ##
##############################################################
# Then the .dockerignore:
printf '**/node_modules\n**/.meteor/local\nDockerfile\n' > .dockerignore
# Push to GitHub
git init && git add . && git commit -m "Meteor 3.5 + meteor-base Dockerfile"
gh repo create meteor-railway-test --private --source=. --push
Then on Railway:
- New Project, then New → Database → MongoDB
- New → GitHub Repo, select
meteor-railway-test(Railway imports it but doesn't deploy yet) - On the app service: Settings → Networking → Generate Domain (port
3000) and Settings → Deploy → Healthcheck Path =/ - On the app service's Variables tab:
MONGO_URL=${{ MongoDB.MONGO_URL }}andROOT_URL=https://${{ RAILWAY_PUBLIC_DOMAIN }} - Deploy the staged changes, wait out the build, open the URL
That's the whole thing. The rest of the post explains why each piece exists and what to do when it misbehaves.
How Meteor runs in production
In development, meteor run compiles on the fly, embeds a local Mongo, and assumes hot reload. Production uses a different flow, and understanding it explains every step in this guide:
- Build:
meteor buildcompiles the whole app into a bundle: a folder of plain JavaScript, ready to run on Node, with no Meteor CLI involved. - Runtime: the bundle runs with
node main.jsplus a handful of environment variables. It only needs the right Node version and a reachable MongoDB.
meteor-base packs exactly this flow into a multi-stage Dockerfile: a heavy stage that builds, a light stage that runs.
Prerequisites
- Node 24+ and npm installed on your machine (Meteor 3.5 requires Node 24)
- A GitHub account (Railway deploys from a repo)
- A Railway account (we create the project and the database in step 5)
- The GitHub
ghCLI is optional, but it shortens step 4
Step 1: create the Meteor app locally
First, install the Meteor CLI. The meteor npm package is only an installer: npx meteor downloads the real tool and puts the meteor command on your PATH. The official docs even warn against adding that package to your project's package.json.
npx meteor
With the CLI installed, create the app:
cd ~/Documents
meteor create meteor-railway-test --release 3.5
cd meteor-railway-test
What you get is a minimal React app with a client/ and server/ structure, plus the .meteor/ folder that pins the framework version and Atmosphere packages. Check that .meteor/release reads METEOR@3.5: that file is the source of truth for the version, and it drives two choices later on, the meteor-base image tag and the Node version at runtime.
To validate before any deploy, run meteor run and open http://localhost:3000.
Step 2: create the Dockerfile
Create a Dockerfile at the project root with this content. It's the example/default.dockerfile from the meteor-base repo, adjusted for an app living at the repo root (the original example assumes an app/ subfolder):
# The tag must match .meteor/release
FROM geoffreybooth/meteor-base:3.5
COPY ./package*.json $APP_SOURCE_FOLDER/
RUN bash $SCRIPTS_FOLDER/build-app-npm-dependencies.sh
COPY . $APP_SOURCE_FOLDER/
RUN bash $SCRIPTS_FOLDER/build-meteor-bundle.sh
# Node version matching the Meteor release, per docs.meteor.com/changelog
FROM node:24.15.0-alpine
ENV APP_BUNDLE_FOLDER=/opt/bundle
ENV SCRIPTS_FOLDER=/docker
RUN apk --no-cache add bash ca-certificates
COPY $SCRIPTS_FOLDER $SCRIPTS_FOLDER/
COPY $APP_BUNDLE_FOLDER/bundle $APP_BUNDLE_FOLDER/bundle/
RUN bash $SCRIPTS_FOLDER/build-meteor-npm-dependencies.sh
ENTRYPOINT ["/docker/entrypoint.sh"]
CMD ["node", "main.js"]
The two FROM lines are the whole trick: a multi-stage build, two images in sequence. Stage 1 (the builder) starts from geoffreybooth/meteor-base:3.5, which ships with the Meteor CLI preinstalled. It installs npm dependencies, runs meteor build, and produces the bundle. This image is big (hundreds of MB of toolchain) and slow to assemble, and none of it reaches production. Stage 2 (the runtime) starts from node:24.15.0-alpine, a minimal image with just Node, and copies over only the finished bundle and the entry scripts (COPY --from=0). The result is a small final image, with no Meteor CLI and no source code inside.
Building inside Docker also solves an official guide warning for free: the bundle must be generated for the architecture it will run on (that's what the --architecture os.linux.x86_64 flag does in manual builds). Here the build already happens on the same Linux x86 as the runtime, so the problem never shows up. The meteor-base README lists two more wins: the machine doing the building (Railway's builder, a CI server) needs no Node or Meteor installed, and the alpine base of the final stage gives security scanners much less surface than Debian images.
The scripts called along the way come predefined in the base image, along with the $APP_SOURCE_FOLDER and $SCRIPTS_FOLDER variables:
build-app-npm-dependencies.sh: runs your app'snpm install. TheCOPYofpackage*.jsoncomes before theCOPYof the rest of the code on purpose: Docker caches layers, so as long as dependencies stay the same, later builds skip the wholenpm install.build-meteor-bundle.sh: runsmeteor buildand drops the bundle in/opt/bundle.build-meteor-npm-dependencies.sh: the bundle carries its ownprograms/server/package.json; this script installs those production dependencies in the final stage.entrypoint.sh: executes theCMD(node main.js). It also offers a startup hook: if your Dockerfile saves astartup.shinto$SCRIPTS_FOLDER, the entrypoint runs that script before the app. Handy for migrations or setup that has to happen on every boot.
One thing to keep matched: the Node version. Each Meteor release ships a specific one, listed in the changelog, and the official guide warns that a mismatched version produces runtime errors. Two ways to check: meteor node -v in the app directory, or the .node_version.txt file inside the generated bundle. The meteor-base README adds a third that skips having Meteor installed on your machine:
docker run --rm geoffreybooth/meteor-base:$(cat ./.meteor/release | cut -c8-99) meteor node --version
For Meteor 3.5, the official meteor-base example uses node:24.15.0-alpine. If you change the Meteor version, change the meteor-base tag and the Node image together.
And a variation to know about: if the app uses packages with native compilation (bcrypt, sharp, canvas), the plain alpine image fails for lack of a compiler. In that case, start from the app-with-native-dependencies.dockerfile in the meteor-base repo, which installs the build toolchain in the final stage.
Step 3: create the .dockerignore
Create a .dockerignore file at the root:
**/node_modules
**/.meteor/local
Dockerfile
This file matters because of how Docker builds work. Railway first packs your whole project folder and ships it to the builder, the so-called build context. Without a .dockerignore, that upload includes node_modules (which gets reinstalled inside the container anyway) and .meteor/local, Meteor's local build cache, which easily passes 1 GB. Ignoring both turns minutes of upload into seconds and keeps Docker's layer cache intact.
Before pushing anything, an optional check: if you have Docker on your machine, the meteor-base repo ships an example/compose.yml that builds your image and starts a linked Mongo next to it. Copy it to the project root, adjust the paths if needed (the example assumes the app in an app/ subfolder), and run docker compose up; the app shows up at http://localhost/. It's worth doing to catch a Dockerfile error in seconds on your machine, instead of finding out after minutes of building on Railway.
Step 4: push the code to GitHub
git init
git add .
git commit -m "Meteor 3.5 + meteor-base Dockerfile for Railway"
gh repo create meteor-railway-test --private --source=. --push
Without the gh CLI: create an empty repo on GitHub's website, then:
git remote add origin git@github.com:YOUR_USERNAME/meteor-railway-test.git
git branch -M main
git push -u origin main
The repo matters because Railway works on continuous deployment: every push to the connected branch triggers a new build. The alternative is their CLI (railway up), which uploads your local folder directly. That works for testing without a repo, at the cost of losing the deploy history tied to commits.
Step 5: create the project and MongoDB on Railway
If you don't have a Railway project yet, create one from the dashboard: New Project. The project is the container for everything that follows: app, database, variables, and the private network that connects the services.
Inside the project, create the database:
- New button in the top right corner of the canvas (or
CMD + K) - Database, then pick MongoDB
Within seconds the service shows as "Online" on the canvas. Under the hood, Railway deploys the official mongo image from Docker Hub, with the start command adjusted for their private network (IPv6 bind), and exposes the variables MONGOHOST, MONGOPORT, MONGOUSER, MONGOPASSWORD, and the full MONGO_URL, the one we'll reference in step 8. The service comes up as an "Unexposed service": no public domain, reachable only through the project's internal network. For a database, that's exactly what we want.
You'll also notice a volume attached to it (named in the mongodb-volume pattern). It's there because Railway containers are ephemeral: the filesystem resets on every deploy, and a database has to survive that. The volume is a persistent disk where Mongo writes its data, created and mounted by default.
One expectation to set: this Mongo runs standalone, without a replica set. For this guide's test it covers everything; the limitations section at the end explains what changes in production.
Step 6: create the app service on Railway
In the same project where MongoDB is already running:
- New button in the top right corner of the canvas (or
CMD + Kand type "new service") - GitHub Repo
- Authorize access if it's your first time and select
meteor-railway-test
Railway looks at the repo root and, finding a Dockerfile (capital D, the name is case-sensitive), builds with Docker instead of the language autodetector. The confirmation shows up in the Build Logs: "Using detected Dockerfile!". For non-standard layouts (another name or folder) there's the RAILWAY_DOCKERFILE_PATH variable.
Importing the repo doesn't trigger a build: Railway stages the service and waits for you to hit Deploy. Finish steps 7 and 8 first — domain, healthcheck, MONGO_URL, ROOT_URL — then deploy once with everything already in place, and it comes up clean on the first try.
The app has to live in the same project as Mongo because the project is the network boundary. Services inside it share a private internal network (hostnames on *.railway.internal): they talk over that network, traffic stays inside Railway, and Mongo can remain unexposed, invisible from outside.
Step 7: generate the domain and set the healthcheck
On the app service: Settings → Networking → Generate Domain. If it asks for the port, enter 3000. This works as soon as the service exists, no build required. You don't need to copy the URL anywhere: generating the domain populates RAILWAY_PUBLIC_DOMAIN, a variable Railway injects into the service, and step 8 references it. Behind the domain sits Railway's edge proxy, which terminates TLS (you never configure a certificate) and forwards traffic to the port your app listens on.
While you're in Settings, set the healthcheck: Settings → Deploy → Healthcheck Path, value /. A Meteor app answers 200 on / as soon as the server is up, so no extra code is needed. What this buys you is zero-downtime deploys: with a path configured, Railway holds each new deployment out of rotation until the endpoint returns HTTP 200, and the previous deployment keeps serving until then. Without it, a new deployment goes active with no proof of readiness. The default timeout is 300 seconds, and the check hits the PORT Railway injects.
The check has a boundary, though: Railway probes the endpoint at deploy time only. Once a version is live, the healthcheck stops; crashes still trigger restarts, but continuous HTTP liveness monitoring is on you.
Step 8: configure the environment variables
Open the Variables tab on the app service. Double-check which service you're on: every service has an identical Variables tab, and these two belong to the app, with Mongo's connection handled by the reference.
| Variable | Value | What it does |
|---|---|---|
MONGO_URL | ${{ MongoDB.MONGO_URL }} | Database connection string |
ROOT_URL | https://${{ RAILWAY_PUBLIC_DOMAIN }} | Public URL Meteor uses to build absolute links, configure the DDP WebSocket, and generate email URLs |
Both values use Railway's reference syntax instead of literals. ${{ MongoDB.MONGO_URL }} means "use the MONGO_URL variable exposed by this project's service named MongoDB": if the database password changes, the app follows along, and the referenced URL points to the private hostname (mongodb.railway.internal), so traffic between app and database stays inside Railway. If your database service has a different name, adjust the reference to match. ROOT_URL points the same mechanism at the service itself: ${{ RAILWAY_PUBLIC_DOMAIN }} (no service name needed for your own variables) resolves to the domain from step 7. Hardcoding the URL works too, but the reference survives domain changes with zero manual edits.
After saving, Railway shows a warning on ROOT_URL about egress fees, suggesting RAILWAY_PRIVATE_DOMAIN instead. Ignore it here. That heuristic targets connection strings pointed at public endpoints (a database URL, for example), and ROOT_URL is a different animal: the server never connects to it. Meteor hands the value to browsers for absolute links, the DDP WebSocket, and hot code push, so it has to be the public domain. The private one resolves to *.railway.internal, which no browser can reach.
Beyond these two, the full set of variables the Meteor bundle understands:
MONGO_URL(required): without it the bundle won't even boot. In dev you never set it becausemeteor runembeds a local Mongo; in production it's on you.ROOT_URL(required): the Meteor 3 bundle refuses to boot without it, throwingError: Must pass options.rootUrl or set ROOT_URL in the server environment. It feeds absolute links, the DDP WebSocket, and hot code push.PORT: Railway injects it and the bundle respects it. Leave it unset.MONGO_OPLOG_URL(optional): enables reactivity via oplog tailing. It requires a replica set, so it stays out of this test's standalone Mongo. See the limitations section.METEOR_SETTINGS(optional): the production equivalent ofmeteor --settings settings.json. More on managing it below.
If your app uses Meteor.settings, the whole settings file becomes one more variable. In dev you run meteor run --settings settings.json and keep the file out of git when it carries secrets (a committed settings.example.json documents the shape). In production, the file's entire content goes into METEOR_SETTINGS. Minify it first (jq -c . settings.json) and paste it as a single line: Railway accepts multiline values, and its Raw Editor takes .env or JSON pastes, but one-line JSON leaves no room for parsing surprises.
Two Railway features pair well with it. If the JSON carries API keys, seal the variable: a sealed value still reaches builds and deployments, but disappears from the UI and the API for good. Sealing has no undo, so the source of truth stays in your local file or password manager. And the bundle reads METEOR_SETTINGS once, at boot, so every edit applies through a redeploy. One rule that holds on any platform: everything inside Meteor.settings.public ships to the browser, so secrets live outside public.
Variable changes apply in two steps: adding, editing, or removing them becomes a set of staged changes, and you review and hit Deploy to make them count. Until then, the service keeps running with the old values.
Step 9: watch the build and validate
The first build takes several minutes: it pulls the meteor-base image (~600 MB), installs dependencies, and compiles the bundle. Later builds reuse the layer cache and get much faster.
Once the deploy turns Active, validate on 3 fronts:
- Deploy Logs: you should see the entrypoint executing
node main.jsand the server listening on the port. Restarts in a row point to a boot problem; the troubleshooting section covers the common cases. - Public URL: the default Meteor app renders with the click counter button.
- Database: click the button a few times and refresh the page. If the number persists, writes and reads to Mongo work end to end.
Troubleshooting
Build fails (Build Logs tab). Almost always the Dockerfile or the context. Check that .dockerignore exists and that the meteor-base tag matches .meteor/release. An "out of memory" error during meteor build is rare on Railway, and if it shows up, the build variable TOOL_NODE_FLAGS=--max-old-space-size=4096 fixes it.
Container boots and dies within seconds (Deploy Logs tab). Read the first error line:
Must pass options.rootUrl or set ROOT_URL in the server environment:ROOT_URLis missing. This happens if you deployed before finishing step 8; set the variable and deploy the staged changes.MONGO_URL must be set: the variable is missing. Check that it was created on the app service and that the reference uses your database service's actual name.MongoServerSelectionErroror a connection timeout: the app has no path to Mongo. Confirm both services share the same project/environment and that the reference resolves to the internal URL.- A missing module error: usually a native dependency absent from the alpine stage; move to the native-dependencies Dockerfile.
The app opens but the WebSocket falls back to long-polling, or links come out wrong. ROOT_URL is missing or lacks https://.
Assets or file uploads disappear after a redeploy. Railway containers are ephemeral: the filesystem resets on every deploy. Persistent state lives in the database or in external storage (S3 and friends). The volume attached to the Mongo service exists for exactly this: keeping the database's data across restarts.
Limitations of this setup (and when they matter)
Standalone Mongo: reactivity falls back to polling
Meteor's reactivity (publications updating in real time) has 3 possible engines: polling (querying the database every ~10 s), oplog tailing (reading the replication log), and, since Meteor 3.5, Change Streams. The last two require a replica set.
With this guide's standalone Mongo, the whole app works, with reactivity running in polling mode: updates between clients can take up to ~10 s, and CPU cost grows with the number of observers. For tests and small apps, irrelevant. For production with real reactivity: MongoDB Atlas or a Mongo with a replica set, and then MONGO_OPLOG_URL or Change Streams come into play.
Scaling beyond 1 replica
Railway distributes requests randomly between replicas and, per its own docs, doesn't support sticky sessions. For Meteor this matters less than the old "requires sticky sessions" advice suggests: a WebSocket is a single TCP stream, so each client stays pinned to its replica for the life of the connection, and with the app on HTTPS the DDP connection runs as wss://, which rarely falls back to the long-polling mode that would need session affinity.
The practical limits come first anyway: without oplog, each replica polls Mongo on its own and the reactivity cost multiplies by the replica count, and piling vCPU onto a single replica has a low ceiling, since the bundle is a single-threaded Node process.
Assets served by the Node process itself
The bundle serves static files straight from the Node process. That works for tests and low traffic; for serious production, a CDN in front takes that load off the app. The Meteor guide documents the path: a CDN with origin support (CloudFront and similar) plus WebAppInternals.setBundledJsCssPrefix() to point the JS and CSS bundles at it.
References
- disney/meteor-base: the image and the Dockerfile examples
- geoffreybooth/meteor-base on Docker Hub: available tags per Meteor version
- Meteor changelog: the Node version shipped with each release
- Meteor Guide: Deployment: environment variables and production concepts
- Railway Docs: Dockerfiles, Variables, and Healthchecks: builder, reference variables, and the deploy gate