Tutorials
How We Deploy: Railway, Staging, and One Branch to Production
Set up a Railway project with separate staging and production environments mapped to git branches, commit your deploy config instead of leaving it in a dashboard, and keep a human on the promotion decision.
On this page
You can push to GitHub, and you’ve got a project you’d like to actually put online. Here’s the problem you hit almost immediately: if you only have one place things run, then testing a change and running the real thing for real users are the same environment. Something you’re trying out — a schema change, a half-finished feature, a config tweak you’re not sure about — can break what an actual visitor sees, at the moment they see it. You need two environments: one to test in, and one that’s live, kept genuinely separate from each other. And you need a way to move from the first to the second that’s clear, boring, and repeatable, rather than a manual ritual you have to remember correctly every time.
This is how we run techdad.io itself on Railway: a staging branch and a main branch, each with its own auto-deploying environment, and the deploy configuration committed to the repo instead of buried in a dashboard somewhere. Here’s the setup, and the reasoning behind each piece of it — not just the clicks, since Railway’s UI will change over time and the underlying model won’t.
The model, in one sentence
One git branch per environment, each auto-deploying on push. Push to staging, and the staging site updates on its own. Merge staging into main, and the production site updates on its own. Nothing deploys without a corresponding push to its branch, and — this is the part that actually matters — nothing reaches production without going through main first.
Concretely, for us: staging deploys to staging-www.techdad.io, and main deploys to the live www.techdad.io. That’s the whole model. Everything below is just making it concrete on Railway.
Setting it up on Railway
Create the project. From the Railway dashboard, click New Project, then choose the GitHub repo option and select your repository. Railway needs your GitHub account linked the first time you do this. You’ll be offered “Deploy Now,” which builds immediately, or “Add Variables” first, if you need to set environment variables — an API key, say — before the first build runs.
Add a second environment. A new Railway project starts with a single production environment. This is the part that’s easy to skip if you’re used to platforms where “environment” just means a folder of config: on Railway, an environment is a full, isolated copy of your project’s services, each with its own variables and its own network — not a flag inside one shared setup. Open the environment dropdown in the top navigation and choose New Environment. You can duplicate your existing environment (this copies its services and variable structure across, which is usually what you want when spinning up a staging clone) or start from empty. Name it staging.
Point each environment at its branch. In each service’s settings, under Source → Branch Deployments, set the branch that environment should track — main for production, staging for staging. Once that’s wired up, a push to either branch triggers a deploy in the matching environment automatically. No manual “deploy” click, no separate CI step to maintain, no chance of deploying the wrong branch to the wrong place by hand.
That’s the mechanical setup, and it’s genuinely just a few dashboard clicks. The next two pieces are what make it durable rather than fragile six months from now.
Commit your deploy config
It’s tempting to just set your build and start commands in the Railway dashboard and move on. Don’t — put them in a railway.json file in your repo instead. Railway calls this “config as code,” and everything you’d otherwise set in the dashboard’s build and deploy settings can live here instead:
{
"$schema": "https://railway.com/railway.schema.json",
"build": {
"builder": "RAILPACK",
"buildCommand": "astro build"
},
"deploy": {
"startCommand": "node ./dist/server/entry.mjs"
}
}(Those exact commands are ours — we build the site with Astro. Yours will differ; the file structure and the habit are the point, not these specific strings.)
Config defined this way always takes precedence over whatever’s set in the dashboard, and it deploys automatically alongside the rest of your code — there’s nothing extra to remember to update. The real win isn’t the specific commands, it’s that your deploy configuration is now reviewable in a pull request, version-controlled alongside the code it deploys, and reproducible by anyone who clones the repo and connects it to Railway fresh. Compare that to a dashboard-only setting, which lives nowhere your team can review it, and which a new person on the project won’t even know exists until something breaks.
Keep data separate
Each Railway environment gets its own variables, scoped to that environment only — a change to staging’s configuration doesn’t touch production’s, and vice versa. Use this to give each environment its own DATABASE_URL, pointing at its own database, and the same goes for any other environment-specific secret, like a third-party API key you don’t want a test environment hammering with real traffic.
The reason separate databases matter is simple but easy to get burned by if you skip it: if staging and production share a database, then testing on staging means testing against real user data. A bad migration, a debug script left running, a seed command run against the wrong target — any of these can corrupt or lose production data, from an environment that was supposed to be the safe one. Separate databases mean staging is genuinely safe to break, which is the entire point of having a staging environment in the first place. An environment that can still touch production data isn’t really a staging environment, no matter what you’ve named it.
The promotion flow
Put the pieces together and here’s the actual day-to-day flow:
- Work lands on
staging— a feature branch gets merged in, staging auto-deploys, and you (or whoever’s building) checks it on the staging URL. - It gets reviewed there — read the diff, click through the actual running result, whatever your review bar is for this project.
- Only a human-approved merge of
stagingintomainreaches production. That merge is the promotion decision, and it should stay a deliberate, human call — not something that fires automatically just because staging looks fine at a glance. mainauto-deploys, and the change is live, on its own database, serving real traffic.
That third point is worth dwelling on, because it’s the one people are tempted to automate away. It would be easy to wire staging’s deploy to also merge into main once some automated check passes — but the entire value of the two-environment model collapses if you do that, because the whole point was to have a person confirm “yes, this is actually ready for real users” before it reaches them. We use exactly this gate on our own projects: nothing reaches a live production URL without someone deciding, on purpose, in the moment, that it’s ready. It’s the identical discipline behind the ship decision described in How We Shipped an AI-Literacy Game in About a Day — a human made that same kind of promotion call there too, after the automated and reviewed work was already done.
What to take with you
If your stack looks nothing like Astro and Node, the specific commands above won’t transfer — and that’s fine, they were never supposed to. What should transfer is the shape: one branch per environment with auto-deploy, deploy config committed in the repo instead of dashboard-only, separate databases (and secrets) per environment, and a human on the promotion decision. Build that shape once, on Railway or on whatever platform you actually use, and you stop having to think about it again. You just push to the right branch, and trust the rest.
Next step
Curious what this exact deploy pipeline actually shipped? How We Shipped an AI-Literacy Game in About a Day is the full build story that this same staging → production model supported, right down to the human-approved merge that put it live.