Treating Railway Deploys Like Code: What railway.json Actually Buys You
Dashboard-only Railway configuration drifts silently. Committing railway.json makes deploys reviewable and reproducible — here's what it controls, how to override Nixpacks safely, and where the file falls short.
06 Dec 2025, 04:31 UTC

You click around the Railway dashboard to fix a build command, it works, and three weeks later nobody remembers what the setting was — or that staging still has the old one. That drift between what's in the repo and what's in the platform UI is exactly what Railway's railway.json file is meant to prevent. The takeaway: put your deploy configuration in version control, let the dashboard be a read-only mirror, and use Nixpacks overrides only where the defaults genuinely fall short.
The problem with dashboard-first configuration
Railway's project model is pleasant: you connect a GitHub repo, it detects your stack with Nixpacks (its build system that analyzes source code to produce a container image without a Dockerfile), and pushes to a branch trigger deployments. The friction starts when configuration lives only in the UI. A teammate changes the start command for production but not staging. A new service gets created with slightly different settings. Nothing is reviewable in a pull request, and nothing is reproducible if you recreate the project.
Infrastructure-as-code doesn't have to mean Terraform. For a single-platform setup, a committed config file that Railway reads on each deploy gives you the two things that matter most: diffs in code review, and a single source of truth.
What railway.json controls
Place railway.json in the root of the repo (or the service's root directory in a monorepo). It covers the build and deploy lifecycle: which builder to use, build and start commands, restart policy, and healthcheck settings. Environment variables themselves stay in Railway — scoped per environment like production and staging — but the shape of the deploy lives in git.
A realistic example for a Node.js API that needs a database migration before the server starts:
{
"$schema": "https://railway.app/railway.schema.json",
"build": {
"builder": "NIXPACKS",
"buildCommand": "npm ci && npm run build"
},
"deploy": {
"startCommand": "npm run migrate && node dist/server.js",
"restartPolicyType": "ON_FAILURE",
"restartPolicyMaxRetries": 5
}
}The $schema line gives you autocomplete and validation in editors that support JSON Schema. Commit this, push, and the next deployment picks it up — verify by checking the build log in the Railway dashboard, which shows the build command actually executed.
Overriding Nixpacks without abandoning it
Nixpacks is good at the common cases: it sees package.json or requirements.txt and produces a reasonable image. Where it gets fragile is non-standard setups — a system library your dependency compiles against, a monorepo where the app isn't at the root, or a build tool it doesn't recognize. Symptoms show up as build failures in the deploy log, usually a missing package or an unrecognized start command.
You have three escape hatches, in increasing order of commitment:
- Set
buildCommandinrailway.jsonto adjust what runs inside the Nixpacks build. - Add a
nixpacks.tomlfor finer control over build phases and system packages. - Switch
buildertoDOCKERFILEand own the image entirely.
Start with option 1. Jumping straight to a Dockerfile means you now maintain base image updates and layer caching yourself — a real cost for a small team.
Private networking is the quiet win
One config-adjacent decision worth making deliberately: within a single Railway project, services can reach each other over private networking using internal DNS names (exposed as variables like the service's internal hostname) instead of public URLs. For a typical API-plus-database or API-plus-worker setup, this means your database never needs a public endpoint at all.
To check it works, deploy two services in one project, copy the internal host variable from the provider service's variables tab into the consumer's environment, and exec a simple connection test (for example, hitting the internal health endpoint from the consumer service's shell in the dashboard). The limitation to design around: private networking is project-scoped. If you split services across projects — say, to separate teams or billing — you're back to public endpoints and need TLS and authentication on them.
The trade-off: config drift cuts both ways
Committing railway.json doesn't automatically keep things in sync — it just moves where sync can break. If someone edits settings in the dashboard that the file also controls, behavior depends on which source Railway prioritizes for that setting, and it's easy to end up confused about why a deploy changed. The practical rule: pick the file as authoritative, treat dashboard edits as emergencies-only, and when an emergency edit happens, backport it into the repo the same day. A quick audit habit — diff the dashboard settings against the file monthly — catches drift before it bites.
Also note that railway.json doesn't manage environments, variables, or service topology. If you outgrow it and need the whole project reproducible, that's when Railway's Terraform provider or API becomes worth the complexity.
Where to start
Take your most-deployed service, write down the build command, start command, and restart policy currently in the dashboard, and encode them in a railway.json. Push it to a branch with a PR environment or staging target first, confirm the build log shows your commands running, then merge. Fifteen minutes of work, and your next "who changed the deploy settings?" incident answers itself with git blame.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.