One public repo carries four separate Railway debugging documents — a question file, a solution file, a problem-scope file, and a fix commit explaining what Railway's path resolution actually does. That is not a documentation habit. That is the fossil record of a team that lost days to a build system that guesses.
The guesses have names: Railpack, Nixpacks, and Dockerfile autodetect, plus a config file whose paths resolve from the repo root no matter which service directory you meant. In 2026, deploying a monorepo to Railway means spinning all three builders at once and hoping the right one wins. This post names the wheel, catalogs where it lands wrong, and gives you the explicit-pinning checklist that stops the spin.
The core deliverable, up front — the detection landscape in one table:
| Builder | Config file it honors | 2026 status | What actually happens in a monorepo |
|---|---|---|---|
| Railpack (default) | railpack.json, railway.toml/railway.json build settings | Default since the Nix migration; Nixpacks docs redirect to it | Walks from the root looking for a recognizable project; finds no app at a monorepo root and fails with "could not determine how to build" |
| Nixpacks (legacy) | nixpacks.toml, only when builder = "NIXPACKS" is pinned | Maintenance mode; recognized values are now RAILPACK and DOCKERFILE | A nixpacks.toml sitting in the repo is silently ignored under the default builder — same error on every retry, no warning that the file is dead |
| Dockerfile | dockerfilePath in railway.toml/railway.json, or a Dockerfile found by autodetect | Auto-used when present, but path rules surprise | dockerfilePath resolves from the repo root, not the service's Root Directory; a root-level railway.toml can force every service onto one Dockerfile |
If you take one sentence from this post: under Railway's 2026 defaults, the builder is chosen by detection order, the config file is chosen by builder, and the paths are chosen by repo root — and none of the three choices tells you when it guessed wrong.
The three builders and the configs they honor (or silently ignore)
Railway's builder story starts with Nixpacks, the open-source builder Railway announced nearly three years ago that became the default way to turn pushed code into images. It worked for roughly 80% of users — and with over 14 million apps built, the remaining 20% meant something like 200,000 users hitting limitations regularly. Railway's answer was Railpack: a from-scratch Go + BuildKit builder that generates a low-level build plan per step instead of Nixpacks' higher-level phases. The numbers Railway published at beta were striking — 38% smaller images for Node, up to 77% smaller for Python — plus better caching, first-class SPA support, and a Mise-based toolchain that no longer needs a Railpack release to support the latest Bun or Node. Railpack became the default; Nixpacks went into maintenance mode; the old /reference/nixpacks and /builds/nixpacks docs now redirect to /builds/railpack.
That migration is where the roulette wheel starts spinning. The three builders each read a different config surface, and the default flip did not move anyone's config along with it:
- Railpack reads
railpack.json(validated againstschema.railpack.com) plus the build settings inrailway.tomlorrailway.json. It does not readnixpacks.toml. Ever. - Nixpacks reads
nixpacks.toml— but only if the service's builder is still set toNIXPACKS, a value the current config spec considers deprecated. Teams that pin it get a dashboard deprecation warning for their trouble. - Dockerfile builds trigger when
builder = "DOCKERFILE"is set with adockerfilePath, or when autodetect finds aDockerfileit likes. But "finds" is doing heavy lifting in a monorepo, as we will see.
The failure mode this produces is the worst kind: silent. Multiple teams have committed the same sad sequence — add install overrides to nixpacks.toml, redeploy, watch the identical PDF-generation or Prisma-generate error recur, and only later discover the file was never read because the default builder changed underneath them. One commit message puts it plainly: "The previous nixpacks.toml was being ignored because Railway's default builder is Railpack, not Nixpacks." Another team watched Railway default to Railpack's Node autodetect instead of their TDLib Dockerfile, running npm run build and an Expo web export the service never needed. The fix in every case was the same shape: delete the dead config, write the live one, and pin the builder explicitly so the next default change cannot re-break the deploy.
The monorepo failure catalog
Single-service repos mostly survive autodetect. Monorepos collect all five of these failure modes, each attested by a real repo's debugging trail:
1. The dead nixpacks.toml. A monorepo that grew up on Nixpacks keeps a nixpacks.toml per service. Under the Railpack default, every one of those files is decorative. The deploy fails — "No start command detected" for a uv-based Python project is one reported instance — and the natural fix (edit nixpacks.toml, push, watch it fail identically) burns commits because the file being edited is not the file being read. One team's escape hatch was builder = "NIXPACKS" in railway.toml to force the old builder to honor the old config.
2. "Railpack could not determine how to build the app." With no Root Directory set, Railpack inspects the repo root. A monorepo root typically holds apps/, packages/, docs, and maybe a workspace package.json with no start command — so detection either fails outright or misdetects. Reported detection output for one such root: Detected: Cpp, Staticfile, Shell. That is the builder telling you it found everything except your app.
3. The wrong-directory build. Even when detection "succeeds," it can succeed at the wrong level: Railpack detects the Node workspace at the root, looks for a start command in the root package.json, finds none, and fails — while each service's real app sits untouched in apps/api and apps/web with its own Dockerfile. The builder built the directory you did not mean.
4. The root railway.toml that captures every service. Because config resolution starts at the repo root, a root-level railway.toml with dockerfilePath = "apps/backend/Dockerfile" can force all services onto the backend's image. One team's incident log describes exactly this: every service building a 46-step Dockerfile meant for a different service, fixed only by deleting the root file so each service's own config applied. Another team hit the mirror image — a service with rootDirectory = packages/threat-cloud still resolving Dockerfile against the monorepo-root config and building the wrong 46-step image instead of its own 10-step one.
5. dockerfilePath is root-relative, always. The single most expensive misunderstanding: teams set a service's Root Directory to apps/web and then write dockerfilePath = "Dockerfile", expecting it to resolve inside the service directory. It does not. The path resolves from the repo root, so the correct value is apps/web/Dockerfile — and the build context stays at the repo root too, which is what lets the Dockerfile COPY shared workspace packages. Get this backwards and the build fails with missing-package errors ("/packages/ui-framework": not found) that look like a dependency problem but are really a path problem.
The path-resolution rules, stated plainly
Railway's monorepo path behavior is learnable, but only because teams reverse-engineered it in public. Here are the four rules, with no hedging:
- Root Directory scopes what the service builds — set it per service (dashboard Source settings) to the service's directory, e.g.
apps/api. Without it, detection runs against the monorepo root and usually fails as in mode 2 above. - The config file path does not follow Root Directory. A
railway.tomlorrailway.jsoninside the service directory is not picked up by virtue of the Root Directory setting; you must reference it by its absolute in-repo path (e.g./apps/api/railway.toml). Community deploy guides call this out explicitly because the intuitive thing — config travels with the service directory — is wrong. dockerfilePathis relative to the repo root, not Root Directory. With Root Directoryapps/web, the Dockerfile atapps/web/Dockerfilemust be referenced asapps/web/Dockerfile. WritingDockerfilepoints at a repo-root file that does not exist.- The build context stays at the repo root so Dockerfiles can reach workspace packages. This is the one root-relative behavior that helps: a Turbo
prunestage or aCOPY packages/...step works precisely because the context was not narrowed to the service directory. But it means your Dockerfile'sCOPYpaths must carry theapps/<name>/prefix, and setting Root Directory to the repo root (or leaving it blank) is the coherent choice when the Dockerfile needs the whole workspace.
Worked example for a standard apps/api + apps/web monorepo: create one service per app; set each service's Root Directory to its app directory; give each service its own config referenced by absolute in-repo path; inside each config, pin builder = "DOCKERFILE" and set dockerfilePath = "apps/api/Dockerfile" (root-relative); write the Dockerfile's COPY instructions with the same apps/api/ prefix so they resolve against the repo-root context. Every path in that recipe is explicit, which is exactly why it survives the next default change.
The explicit-pinning checklist
If your monorepo deploys to Railway today, run this checklist before the next incident runs you:
- Pin
builderper service (RAILPACKorDOCKERFILE) in each service's config. Never leave builder selection to autodetect for a service that matters; autodetect is a demo convenience, not a deployment contract. - Delete dead configs. If the builder is Railpack, remove
nixpacks.toml— a present-but-unread config is worse than no config, because it invites edits that cannot work. If the builder is Dockerfile, make sure norailpack.jsonbuild commands linger to confuse the next reader. - Reference configs by absolute in-repo path (
/apps/api/railway.toml), not by paths relative to Root Directory. - Write
dockerfilePathfrom the repo root and keep the build context there; prefix DockerfileCOPYpaths with the service directory. - Verify in the build logs, not the dashboard. After pinning, redeploy once and confirm the logs show the intended builder (
Railpack 0.xvs a Docker build) and the intended Dockerfile step count. The teams that got burned fastest were the ones that trusted settings over logs. - Keep one service's config from capturing others. Audit for a root
railway.tomlthat sets a singledockerfilePath; in a monorepo, the root config is shared blast radius.
What a self-hosted git-push PaaS should do instead
Railway's roulette is not a bug in any one builder — Railpack's smaller images and version-independent toolchains are genuine improvements. It is a UX architecture problem: three detection layers (which builder, which config, which paths) that each guess independently and report success in the vocabulary of whoever guessed last. The monorepo teams above did not misconfigure their apps; they configured them correctly for a builder that was no longer listening, in paths relative to a directory that was never the anchor.
The lesson for a self-hosted git-push PaaS is to invert the default: declare, then detect. One manifest names the builder, the build context, and the config path explicitly; autodetect may propose those values for a brand-new service, but the proposal is written into the manifest where the operator can see it, and nothing downstream ever re-guesses. No silent config format changes on a default flip. No root-relative surprise, because the manifest states the anchor. When the platform ships a new builder generation, existing services keep the pinned one until the operator opts in — the migration is a diff, not a mystery.
That is the build-detection contract worth copying: make the happy path automatic and the steady state explicit. Railway proved the demand for zero-config deploys with 14 million Nixpacks apps; its monorepo incident trail proves the ceiling of guessing. A platform that writes down what it detected, and then stops detecting, gets both.
Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own. Star the repo on GitHub or deploy your first app today.



