Migrate a Railway project to exe.dev
Your Railway services and data on one VM, and your API keys off it.
Shelley handles the setup. About 1 hour.
Prefer a terminal?
curl -fsSL https://exe-staging.dev/deploy/migrate-railway/prompt.txt | ssh exe.dev new --prompt=/dev/stdinA Railway project is a set of services: your apps, Postgres, Redis, maybe
a cron job. All of it fits on one VM as a Docker Compose project, with the
same images, the same variables, and the same *.railway.internal names,
so the services find each other as before.
One thing changes on purpose: your API keys. On Railway, your Stripe and OpenAI keys are variables that anything in the container can read. On exe.dev they go into integrations, which add the key to each request after it leaves the VM.
Nothing changes on Railway until you say so. The examples move a project
with a web service at www.example.com to a VM called my-app.
What you get#
- Your Railway services, variables, and data running on the VM, with the same
*.railway.internalnames, at https://my-app.exe.xyz/. git push exe maindeploying like Railway: build, pre-deploy command, health check, and the old build kept if anything fails.- A list of the API keys that can move into exe.dev integrations, where the VM can't read them.
- A report in
~/railway/report.md, and your steps for the move.
What's running, and where
https://my-app.exe.xyz/
the app, until you make the move
https://www.example.com/
the app, after the move
ssh my-app.exe.xyz
the shell; git push exe main deploys
Deploy with Shelley#
Click Deploy and Shelley will set up Apps from Railway on a new VM, then check that it works. No config-file wrangling required.
Prefer a terminal?
curl -fsSL \
https://exe-staging.dev/deploy/migrate-railway/prompt.txt \
| ssh exe.dev new --prompt=/dev/stdinWhat Shelley is told
Set up Apps from Railway on this VM by following the exe.dev recipe at https://exe-staging.dev/deploy/migrate-railway.md from top to bottom.
The recipe's example VM name is my-app. This VM's name is the output of `hostname`: use it wherever the recipe writes my-app. Skip the step that creates the VM; you are on it.
You are done when all of these are true. Check each one yourself:
- Your Railway services, variables, and data running on the VM, with the same `*.railway.internal` names, at https://my-app.exe.xyz/.
- `git push exe main` deploying like Railway: build, pre-deploy command, health check, and the old build kept if anything fails.
- A list of the API keys that can move into exe.dev integrations, where the VM can't read them.
- A report in `~/railway/report.md`, and your steps for the move.
Read the whole recipe, including the closed details, and do "Rehearse the
move by hand" in order, skipping VM creation; the rest of the page is for
the owner. Use the kit's scripts as published. Change compose.yaml only
where this project needs it, and list the changes in the report.
You need the owner once, early: show them the commands in "Hand over the
project" with this VM's name, and wait until ~/railway/handed-over exists
and each repository in ~/git has a commit. Install the rest meanwhile.
Never run railway commands yourself, never ask for a Railway token, and
never connect to Railway's databases: the addresses in ~/railway are live
production ones. Never run railway/move or handover --move, change DNS,
or run share set-public: the move is the owner's call. Keep the cron
jobs off until then.
Keep variable values out of your replies and the report. Never ask the
owner to paste an API key into the conversation: API keys go into
integrations, which the owner adds; sealed variables the owner sets with
railway/import set over SSH.
If the project needs something exe.dev can't do (public TCP ports other
than HTTP, several regions, MySQL or MongoDB data), put it in the report
and ask.
Finish with the report's summary, https://<vm>.exe.xyz/, the keys that
could move to integrations, and the owner's steps in "Make the move"
with this VM's name and the project's domains.
Finish with the URLs I should open.
Also at /deploy/migrate-railway/prompt.txt. The recipe it follows is /deploy/migrate-railway.md.
How the move works#
- Hand over. On your computer, a script copies the project to the
VM: its settings, variables, and data. You
git pushthe code. - Rehearse. Shelley sets everything up on the VM and checks its pages against Railway's. Railway keeps running.
- Move. The same script stops Railway, copies the data once more, and starts the VM. You point your domain at it.
Shelley never logs in to Railway. The commands that touch Railway are yours.
Where everything goes#
| On Railway | On the VM |
|---|---|
| Services | Docker Compose services in ~/app |
| Railpack, Nixpacks, Dockerfile builds | Railpack or docker build, on git push exe main |
| Variables | ~/app/compose.yaml |
| API keys for other services | Integrations |
| Postgres, Redis, volumes | The same images, data in ~/app/volumes |
| Cron schedules | Crontab lines, in UTC |
| Domains | https://my-app.exe.xyz/ and your custom domain |
railway ssh, railway logs |
ssh my-app.exe.xyz, docker compose logs |
Not copied: Railway Buckets, MySQL, MongoDB, replicas, and regions. Shelley lists any of these it finds in the report.
Hand over the project#
When Shelley asks, run this on your computer, logged in to the Railway CLI. In an empty directory, link the environment to move, usually production, and fetch the script:
railway link
curl -fsSLO https://exe-staging.dev/deploy/migrate-railway/handover
less handover
bash handover my-app.exe.xyz
It only reads from Railway. If it stops at railway ssh, run
railway ssh keys add and try again.
Then push each repository Railway builds from. In its checkout:
ssh my-app.exe.xyz git init -q --bare git/web.git
git remote add exe my-app.exe.xyz:git/web.git
git push exe main
The script prints these lines for each of your repositories.
Look around#
When Shelley is done, read its report and open https://my-app.exe.xyz/:
ssh my-app.exe.xyz cat railway/report.md
This is your app with a copy of your data. It uses your real variables, so if it sends email or charges cards, it does that from here too.
Railway never shows sealed variables again, so the VM has them empty. Set each one: paste the value, press Enter, then Ctrl-D:
ssh my-app.exe.xyz railway/import set web STRIPE_WEBHOOK_SECRET
Take the keys off the VM#
For each API key in the report, make a new key at the vendor, give it to
exe.dev, and revoke the old one. Take Stripe. Create a restricted key in
the Stripe Dashboard, then at the
ssh exe.dev prompt, which asks for the key with hidden input:
exe.dev ▶ integrations add http-proxy --name stripe --target https://api.stripe.com --bearer=- --attach vm:my-app
The VM can now call https://stripe.int.exe.xyz with no key. Point the
app at it, and replace the old key with a placeholder, since SDKs want
one:
ssh my-app.exe.xyz railway/import set web STRIPE_SECRET_KEY added-by-exe-dev
| Service | Point the app at |
|---|---|
| OpenAI | OPENAI_BASE_URL=https://openai.int.exe.xyz/v1 |
| Resend | RESEND_BASE_URL=https://resend.int.exe.xyz |
| Stripe | In code: new Stripe(key, { host: "stripe.int.exe.xyz" }) |
For Anthropic, use the LLM integration with your own key; for Twilio, Postmark, and others, the integration catalog.
Secrets the app checks itself, such as a session secret or webhook
signing secret, stay in compose.yaml.
Make the move#
A day ahead, lower your domain's DNS TTL to 300 seconds. Then, in the directory with the hand-over script:
bash handover my-app.exe.xyz --move
Type yes when asked. It stops your app services on Railway (the
databases stay), copies the data, and starts everything on the VM. Then:
- At your DNS provider, point
www.example.comatmy-app.exe.xyzwith a CNAME. See custom domains. - On your computer:
ssh exe.dev domain add my-app www.example.com
ssh exe.dev share set-public my-app
Changed your mind before step 1? ssh my-app.exe.xyz railway/move undo
prints the commands that start Railway again.
Day to day#
git push exe main deploys. If the build or health check fails, the push
is rejected and the old build keeps running.
On the VM, in ~/app:
| Instead of | Run |
|---|---|
railway logs |
docker compose logs -f web |
railway ssh |
docker compose exec web bash |
railway variable set |
~/railway/import set web KEY value |
railway connect postgres |
docker compose exec postgres psql -U postgres railway |
A backup of the databases and volumes runs every night into ~/backups.
Clean up#
After a week or so, delete the project in the Railway dashboard, revoke
any keys you haven't, and delete ~/railway/variables and
~/railway/data on the VM.
The nuts and bolts#
These are the steps Shelley follows.
Rehearse the move by hand
Create the VM#
On your computer. Give it about four times the size of your data, plus 20 GB:
ssh exe.dev new --name=my-app --disk=40GB
Everything below runs on the VM: ssh my-app.exe.xyz.
Install Docker, Railpack, and the kit#
sudo systemctl enable --now docker.service
curl -sSL https://railpack.com/install.sh | sh
mkdir -p -m 700 ~/railway && cd ~/railway
for f in import deploy restore check backup move; do
curl -fsSLO https://exe-staging.dev/deploy/migrate-railway/$f
done
chmod +x import deploy restore check backup move
| Script | What it does |
|---|---|
| import | Turns the hand-over into ~/app/compose.yaml; sets variables |
| deploy | Builds and releases on git push; rolls back |
| restore | Loads the data into Postgres, Redis, and volumes |
| check | Compares pages with Railway's |
| backup | The nightly backup |
| move | The move, and its undo |
| handover | Runs on the owner's computer |
Then ask the owner to hand over the project,
and wait for ~/railway/handed-over.
Set up the project#
~/railway/import setup
It writes ~/app/compose.yaml: one service per Railway service, with its
image, start command, variables, volume, and *.railway.internal name.
The public service gets port 8000. Railway's domain becomes
my-app.exe.xyz, and the databases get new passwords. It also makes a
git repository in ~/git for each code repository, and prints a plan,
saved in ~/railway/plan.md, listing sealed variables and likely API
keys.
Restore the data, build, and start#
~/railway/restore
for g in ~/git/*.git; do
~/railway/deploy "$(basename "$g" .git)" "$(git --git-dir "$g" rev-parse HEAD)"
done
cd ~/app && docker compose up -d && docker compose ps
deploy builds each code service with its Dockerfile, or with Railpack,
runs the pre-deploy command, starts it, and waits for the health check.
Cron services are built but not scheduled until the move. If a service
fails, docker compose logs <service> says why.
Check against Railway#
List the app's important pages in ~/railway/paths.txt, one per line,
then run ~/railway/check. Expect same for each. Pages with live data
may differ.
Write the report#
Write ~/railway/report.md for the owner: the services, what changed,
sealed variables to set, keys that could move to integrations, anything
exe.dev doesn't do that Railway did, and the steps in
Make the move. No variable values.
Troubleshooting#
A push is rejected with "health check failed"
The new build didn't answer in time, and the old one still runs. The push
output ends with the service's logs. Usually the app listens on a fixed
port instead of PORT, or on localhost instead of all addresses.
An integration answers 403
The name in the address is wrong, or the integration isn't attached to
the VM. On your computer: ssh exe.dev integrations attach stripe vm:my-app.
Frequently asked questions#
Why move the API keys out of the environment?
On Railway, a Stripe or OpenAI key is a variable in the container, where any dependency or crash log can read it, and the platform stores it. On October 21, 2026, Railway disclosed that an unauthorized party may have accessed encrypted environment variables and decrypted some of them, and advised customers to roll all of them. If you're making new keys anyway, give them to exe.dev integrations: the VM can use them but never read them.
Why not give Shelley a Railway token?
A token could read every variable and change or delete the project. The hand-over only needs a few read-only commands, so you run them yourself, and their output goes straight to the VM.
How long is the app down?
A few minutes: from stopping Railway to your DNS change, plus the time to copy the data. Lower your DNS TTL to 300 seconds a day ahead.
My service builds with Nixpacks.
Nixpacks is deprecated on Railway, and Railpack replaces it, so the VM builds it with Railpack. Shelley checks the build and notes any difference. Services with a Dockerfile build from it, as on Railway.
Can I go back?
Until your domain points at the VM, yes: railway/move undo prints the commands that start your services on Railway again. After that, new data exists only on the VM.
What does it cost?
Your exe.dev plan: one VM runs all of the services, databases, and cron jobs, with no usage-based bill.
Updated Oct 30, 2026 · Markdown · manifest.json · Something wrong? Tell us