Configuring Railway Nixpacks for a Multi-Language Monorepo: Builds, Caching, and Safe Deploys
Set up Railway's Nixpacks builder for a multi-language monorepo: per-service root directories, nixpacks.toml customization, build caching, private networking, health checks, and rollback.
24 Jan 2026, 00:08 UTC

The problem: one repo, several services, one build system
Railway's default builder, Nixpacks, detects a single language from files like package.json or go.mod and produces a deployable image with no Dockerfile. That works for a single app, but a monorepo with, say, a Node API and a Python worker breaks the assumption: the root of the repo matches one detector, and the other service builds wrong or not at all.
The fix is to give each Railway service its own root directory and its own nixpacks.toml, then layer on caching, health checks, and a rollback path. This guide walks through that setup. It assumes Railway's current Nixpacks-based pipeline as of late 2026; verify flag names against the Railway docs before production use, since the platform evolves quickly.
Prerequisites
- A Railway account on a plan that fits your workload. The free tier has monthly usage limits; sustained production traffic needs a paid plan.
- A GitHub, GitLab, or Bitbucket repo connected to a Railway project.
- The Railway CLI installed locally (
npm i -g @railway/cli, thenrailway login) for deploys and rollbacks from a terminal. - A repo layout with one subdirectory per deployable service, e.g.
apps/apiandapps/worker.
Step 1: Point each service at its subdirectory
In the Railway dashboard, create one service per deployable unit. For each service, open Settings and set the Root Directory to the service's folder (for example apps/api). Nixpacks then treats that folder as the build context, so the Node detector sees apps/api/package.json and the Python detector sees apps/worker/requirements.txt — no conflict.
Each service gets an isolated build and its own environment variables, so the API and worker can pin different language versions and secrets independently.
Step 2: Customize the build with nixpacks.toml
Drop a nixpacks.toml into each service root to control setup, install, and build phases, and to declare cache directories. Example for the Node API:
# apps/api/nixpacks.toml
[phases.setup]
nixPkgs = ["nodejs_20", "openssl"]
[phases.install]
cmds = ["npm ci"]
cacheDirectories = ["node_modules", ".npm"]
[phases.build]
cmds = ["npm run build"]
[start]
cmd = "node dist/server.js"Key points:
- Pin the language version (
nodejs_20) so a Nixpacks update doesn't silently bump your runtime. The tradeoff: pinned versions may lag behind the latest patches, so plan periodic bumps. - cacheDirectories persist between builds per service. For Node,
node_modulesand the npm cache give the biggest wins. Note that the cache clears whennixpacks.tomlchanges or after a period of inactivity, so don't treat it as guaranteed —npm cimust still work from cold. - Build-time variables from the dashboard can be referenced with
${VAR}syntax. Mark secrets as "Secret" in Project → Variables so they're masked in logs.
Step 3: Wire services together over the private network
Services in the same Railway project reach each other over internal hostnames of the form service-name.railway.internal — no public port needed. If you add the PostgreSQL plugin, Railway injects DATABASE_URL automatically, pointing at the internal hostname. Keep the worker service private (no generated domain) and only expose the API publicly.
Quick check after deploy: from the API service's shell (railway shell or the dashboard terminal), run curl http://worker.railway.internal:PORT/health (substitute your service name and port). A response confirms private networking; a timeout usually means the worker isn't listening on 0.0.0.0.
Step 4: Health checks and zero-downtime deploys
Railway runs health checks during deploys and rolls back automatically to the previous successful deployment if they fail. Configure the path in railway.json in the service root:
{
"deploy": {
"healthcheckPath": "/health",
"healthcheckTimeout": 100
}
}Two rules for the /health endpoint:
- It must respond quickly (within a few seconds) and must not depend on database migrations having completed — otherwise a slow migration fails the check and triggers a rollback loop.
- Run migrations as a separate step, not inside the start command. A common pattern is
railway run "npm run migrate"from CI before promoting, or a dedicated one-off migration service. A failed migration inside the deploy leaves the service crashed rather than rolled back cleanly.
Step 5: Verify the pipeline end to end
- Push a change to the connected branch. In the dashboard's build logs, confirm your custom
phases.install/phases.buildcommands appear and that subsequent builds show cache restoration fornode_modules. - Call the API's public domain and confirm it can reach the worker and database over internal hostnames.
- Deliberately break
/health(return 500), push, and watch the Deployments list: the new deployment should fail and traffic should stay on the previous version. Revert the change. - Test rollback explicitly:
railway rollback <deployment-id>from the CLI (or Deployments → three-dot menu → Rollback in the dashboard). This creates a new deployment from the prior build artifact. Confirm the service is healthy afterward. Remember: rollback does not reverse database migrations, so keep migrations backward-compatible.
Limitations and gotchas
- Nixpacks itself is version-managed by Railway; you pin language versions, not the builder.
- Health-check rollbacks are fast but unforgiving — an endpoint that depends on unready dependencies causes churn.
- Custom domains need a CNAME to
<project>.up.railway.app; TLS via Let's Encrypt can take 5–15 minutes to provision after DNS propagates. - Monitor usage in the dashboard; hitting plan limits can suspend services.
With per-service roots, a pinned nixpacks.toml, and a tested rollback path, a monorepo on Railway behaves like a set of small, independent apps — which is exactly what you want when one of them breaks.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.