---
title: "Migrate a Railway project to exe.dev"
description: "Move a Railway project to an exe.dev VM: services, variables, Postgres and Redis data, volumes, cron, and domains, with API keys kept where the VM can't read them."
url: https://exe-staging.dev/deploy/migrate-railway
category: "Migration"
updated: 2026-10-30
---

# Migrate a Railway project to exe.dev

Your Railway services and data on one VM, and your API keys off it.

- **Software:** Apps from Railway (https://railway.com)
- **Source:** https://github.com/railwayapp/cli
- **License:** MIT (Railway CLI and Railpack); your code is yours
- **Time:** 1 hour
- **Example VM name:** `my-app` (yours will differ; on the VM, `hostname` prints it)
- **Machine-readable:** https://exe-staging.dev/deploy/migrate-railway/manifest.json
- **File:** https://exe-staging.dev/deploy/migrate-railway/backup
- **File:** https://exe-staging.dev/deploy/migrate-railway/check
- **File:** https://exe-staging.dev/deploy/migrate-railway/deploy
- **File:** https://exe-staging.dev/deploy/migrate-railway/handover
- **File:** https://exe-staging.dev/deploy/migrate-railway/import
- **File:** https://exe-staging.dev/deploy/migrate-railway/move
- **File:** https://exe-staging.dev/deploy/migrate-railway/restore

## What you get

- 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.

Running on the VM:

- **Your services**: Each Railway service in Docker Compose, built with its Dockerfile or with Railpack; the public one on port 8000, port 8000, data in `~/app`
- **Postgres and Redis**: The same images as Railway's templates, with your data, data in `~/app/volumes`
- **Integrations**: Your API keys for Stripe, OpenAI, and others, kept by exe.dev outside the VM
- **The move kit**: The hand-over, the scripts, and the report, data in `~/railway`

How it is reached:

- https://my-app.exe.xyz/ (private): the app, until you make the move
- https://www.example.com/ (public): the app, after the move
- ssh my-app.exe.xyz (private): the shell; `git push exe main` deploys

## Deploy with Shelley

Open https://exe-staging.dev/new?prompt=Set%20up%20Apps%20from%20Railway%20on%20this%20VM%20by%20following%20the%20exe.dev%20recipe%20at%20https%3A%2F%2Fexe-staging.dev%2Fdeploy%2Fmigrate-railway.md%20from%20top%20to%20bottom.%0A%0AThe%20recipe%27s%20example%20VM%20name%20is%20my-app.%20This%20VM%27s%20name%20is%20the%20output%20of%20%60hostname%60%3A%20use%20it%20wherever%20the%20recipe%20writes%20my-app.%20Skip%20the%20step%20that%20creates%20the%20VM%3B%20you%20are%20on%20it.%0A%0AYou%20are%20done%20when%20all%20of%20these%20are%20true.%20Check%20each%20one%20yourself%3A%0A-%20Your%20Railway%20services%2C%20variables%2C%20and%20data%20running%20on%20the%20VM%2C%20with%20the%20same%20%60%2A.railway.internal%60%20names%2C%20at%20https%3A%2F%2Fmy-app.exe.xyz%2F.%0A-%20%60git%20push%20exe%20main%60%20deploying%20like%20Railway%3A%20build%2C%20pre-deploy%20command%2C%20health%20check%2C%20and%20the%20old%20build%20kept%20if%20anything%20fails.%0A-%20A%20list%20of%20the%20API%20keys%20that%20can%20move%20into%20exe.dev%20integrations%2C%20where%20the%20VM%20can%27t%20read%20them.%0A-%20A%20report%20in%20%60~%2Frailway%2Freport.md%60%2C%20and%20your%20steps%20for%20the%20move.%0A%0ARead%20the%20whole%20recipe%2C%20including%20the%20closed%20details%2C%20and%20do%20%22Rehearse%20the%0Amove%20by%20hand%22%20in%20order%2C%20skipping%20VM%20creation%3B%20the%20rest%20of%20the%20page%20is%20for%0Athe%20owner.%20Use%20the%20kit%27s%20scripts%20as%20published.%20Change%20compose.yaml%20only%0Awhere%20this%20project%20needs%20it%2C%20and%20list%20the%20changes%20in%20the%20report.%0AYou%20need%20the%20owner%20once%2C%20early%3A%20show%20them%20the%20commands%20in%20%22Hand%20over%20the%0Aproject%22%20with%20this%20VM%27s%20name%2C%20and%20wait%20until%20~%2Frailway%2Fhanded-over%20exists%0Aand%20each%20repository%20in%20~%2Fgit%20has%20a%20commit.%20Install%20the%20rest%20meanwhile.%0ANever%20run%20railway%20commands%20yourself%2C%20never%20ask%20for%20a%20Railway%20token%2C%20and%0Anever%20connect%20to%20Railway%27s%20databases%3A%20the%20addresses%20in%20~%2Frailway%20are%20live%0Aproduction%20ones.%20Never%20run%20railway%2Fmove%20or%20handover%20--move%2C%20change%20DNS%2C%0Aor%20run%20share%20set-public%3A%20the%20move%20is%20the%20owner%27s%20call.%20Keep%20the%20cron%0Ajobs%20off%20until%20then.%0AKeep%20variable%20values%20out%20of%20your%20replies%20and%20the%20report.%20Never%20ask%20the%0Aowner%20to%20paste%20an%20API%20key%20into%20the%20conversation%3A%20API%20keys%20go%20into%0Aintegrations%2C%20which%20the%20owner%20adds%3B%20sealed%20variables%20the%20owner%20sets%20with%0Arailway%2Fimport%20set%20over%20SSH.%0AIf%20the%20project%20needs%20something%20exe.dev%20can%27t%20do%20%28public%20TCP%20ports%20other%0Athan%20HTTP%2C%20several%20regions%2C%20MySQL%20or%20MongoDB%20data%29%2C%20put%20it%20in%20the%20report%0Aand%20ask.%0AFinish%20with%20the%20report%27s%20summary%2C%20https%3A%2F%2F%3Cvm%3E.exe.xyz%2F%2C%20the%20keys%20that%0Acould%20move%20to%20integrations%2C%20and%20the%20owner%27s%20steps%20in%20%22Make%20the%20move%22%0Awith%20this%20VM%27s%20name%20and%20the%20project%27s%20domains.%0A%0AFinish%20with%20the%20URLs%20I%20should%20open.%0A to create a VM whose agent, Shelley, follows this recipe. From a terminal:

```sh
curl -fsSL https://exe-staging.dev/deploy/migrate-railway/prompt.txt | ssh exe.dev new --prompt=/dev/stdin
```

Or follow the steps below by hand.

A 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](/docs/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`.

## How the move works

1. **Hand over.** On your computer, a script copies the project to the
   VM: its settings, variables, and data. You `git push` the code.
2. **Rehearse.** Shelley sets everything up on the VM and checks its
   pages against Railway's. Railway keeps running.
3. **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](/docs/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](/docs/cnames) |
| `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](https://docs.railway.com/cli). In an empty directory, link
the environment to move, usually production, and fetch the script:

```bash
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:

```bash
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/`:

```bash
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:

```bash
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](https://dashboard.stripe.com/apikeys), 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:

```bash
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](/docs/integrations-llm) with
your own key; for Twilio, Postmark, and others, the
[integration catalog](/docs/integrations-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
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:

1. At your DNS provider, point `www.example.com` at `my-app.exe.xyz`
   with a CNAME. See [custom domains](/docs/cnames).
2. On your computer:

```bash
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.

<details id="create-the-vm" data-manual-setup class="dp-details">
<summary>Rehearse the move by hand</summary>

### Create the VM

On your computer. Give it about four times the size of your data, plus
20 GB:

```bash
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

```bash
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](https://exe-staging.dev/deploy/migrate-railway/import) | Turns the hand-over into `~/app/compose.yaml`; sets variables |
| [deploy](https://exe-staging.dev/deploy/migrate-railway/deploy) | Builds and releases on `git push`; rolls back |
| [restore](https://exe-staging.dev/deploy/migrate-railway/restore) | Loads the data into Postgres, Redis, and volumes |
| [check](https://exe-staging.dev/deploy/migrate-railway/check) | Compares pages with Railway's |
| [backup](https://exe-staging.dev/deploy/migrate-railway/backup) | The nightly backup |
| [move](https://exe-staging.dev/deploy/migrate-railway/move) | The move, and its undo |
| [handover](https://exe-staging.dev/deploy/migrate-railway/handover) | Runs on the owner's computer |

Then ask the owner to [hand over the project](#hand-over-the-project),
and wait for `~/railway/handed-over`.

### Set up the project

```bash
~/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

```bash
~/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](#make-the-move). No variable values.

</details>

## Troubleshooting

<details class="dp-details">
<summary>A push is rejected with "health check failed"</summary>

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.

</details>

<details class="dp-details">
<summary>An integration answers 403</summary>

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`.

</details>

## 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](https://blog.railway.com/p/security-disclosure-wednesday-october-21st) 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](https://exe.dev/pricing): one VM runs all of the services, databases, and cron jobs, with no usage-based bill.


## Related

- [Migrate a Heroku app to exe.dev](https://exe-staging.dev/deploy/migrate-heroku.md): Your Procfile, config vars, and Postgres on a VM of your own.
- [Migrate a DigitalOcean Droplet to exe.dev](https://exe-staging.dev/deploy/migrate-digitalocean-droplet.md): Same server, new home. Rehearsed while the droplet keeps serving.
- [Give agents API access without giving them the keys](https://exe-staging.dev/deploy/agent-secrets.md): Your agents call your APIs. The keys stay with exe.dev, outside the VM.
- [Deploy a Docker Compose app from GitHub](https://exe-staging.dev/deploy/docker-compose.md): Your compose.yaml on a VM of its own. git push to deploy.
- [Self-host PostgreSQL](https://exe-staging.dev/deploy/postgres.md): Your own Postgres on a real disk, with backups you've actually restored.
- [Dev, Test, Prod: Choose One, Two, or Three](https://exe-staging.dev/deploy/dev-test-prod.md): You can build, test, and deploy on exe.dev.
