Leaving Railway in an afternoon
A web service, a worker, a Postgres and a cron job, moved from Railway to Gagarin between lunch and dinner. Every command, the one line that changed, and the two habits you drop on the way.
Gagarin vs Railway says where Railway is the better pick. Read that first if you have not decided. This post is for the afternoon after you have: what you type, in what order, and where you lose time.
The project is a common shape. A web service, a worker that reads the same database, a Postgres with a few gigabytes in it, and a cron job that runs a report at three in the morning. Each service has a Dockerfile, because Railway builds from one when it finds one. If yours build with Nixpacks or Railpack, budget an hour at the start to write two Dockerfiles. Nothing else here depends on the shape of your code.
The clock below is a budget for a project this size. Yours will differ by the size of the dump and the state of your DNS.
13:00: Write down what only Railway knows
Three things live on Railway and not in your repository: the environment variables, the database, and the schedule. Get all three onto your machine before you touch anything else.
railway variables --service web --kv > web.env
railway variables --service worker --kv > worker.envOpen both files and sort the lines into three piles. Railway's own:
RAILWAY_*, PORT, and any line that references another service with
${{...}}. Delete those. The database: DATABASE_URL, PGHOST and friends.
Gagarin provides those, so delete them too. Yours: Stripe keys, SMTP passwords,
feature flags. Keep those, and split them: one file per vendor for the
secrets, .env.stripe and .env.smtp, and the plain settings left where they
were, in web.env and worker.env. The reason is in the 14:10 step. For a
project this size you end up with about ten lines across the four files.
The database comes out with the tool it went in with:
railway connect postgres # in one terminal
pg_dump "$DATABASE_URL" -Fc -f before.dump # in anotherA few gigabytes take ten to fifteen minutes. Copy the cron schedule out of the Railway dashboard while you wait.
13:30: A project, a database, and the data
gg login
gg init shop
gg resource add shop/database postgres --size mgg init prints the project's id. Write it into .gagarin.json at the root
of the repository and commit it, so the next session, yours or an agent's,
knows which project this code is. A Postgres that a real application depends
on starts at size m; s is for one you develop against.
Name the Postgres database. A resource on Gagarin hands each service that
depends on it a set of variables prefixed with the resource's own name, so one
called database publishes DATABASE_URL, DATABASE_HOST, DATABASE_USER
and the rest. Your code reads DATABASE_URL today. Name the resource after the
variable and your code stays as it is.
The restore goes through a tunnel that lasts as long as the command does:
gg connect shop/databaseIt prints the same variables a service would hold, rewritten to point at
127.0.0.1, and keeps the tunnel open until you stop it. In another terminal,
paste the printed URL:
pg_restore --no-owner --no-acl -d "postgres://…@127.0.0.1:5432/…" before.dumpStop gg connect and the tunnel closes. A restore done this way exposed
nothing, so there is nothing to clean up afterwards.
While you are here,
gg resource backup shop/databasetakes a copy now, and nightly ones follow on their own. A restore on Gagarin always creates a new resource and never overwrites one, which is why nobody has to approve it at three in the morning, and the backups outlive the database by fourteen days if you ever destroy it.
14:10: The variables, then the two services
On Railway your Stripe key is a line in a variables tab. On Gagarin it is a resource, the same kind of node as the database, and services declare that they need it:
gg resource add shop/stripe external --env-file .env.stripe
gg resource add shop/smtp external --env-file .env.smtpAn external runs nothing. It holds values and publishes them under its own
name: a .env.stripe with SECRET_KEY in it makes shop/stripe publish
STRIPE_SECRET_KEY. So the split by vendor at 13:00 was also a rename. Strip
the vendor prefix from each key in the file, name the resource after it, and
your code reads the same variable names it read on Railway. The plain settings
in web.env and worker.env have no secret in them and belong to one service
each, so they ride along on the deploy itself.
Now the services. gg ship builds the directory, pushes the image and deploys
it. The number after the colon is the port the container listens on. Railway
sets PORT for you. Gagarin asks you to say it once.
gg ship shop/web:8080 --context ./web --env-file web.env --deps database --deps stripe --deps smtp
gg ship shop/worker:9090 --context ./worker --env-file worker.env --deps database--deps is the rest of the configuration. Each one opens the route from the
service to the resource and hands the service that resource's variables in one
step. You write no ${{Postgres.DATABASE_URL}}, because the reference and the
firewall rule are the same declaration. The worker never sees the Stripe key,
because it did not ask for it.
Every arrow in that drawing is a --deps. Nothing else draws one. An arrow
into the database is a route and a password; an arrow into a key is the key.
gg deps add prints the variable names it handed over, so you never guess
what a service now holds. A later deploy that forgets --deps withdraws
nothing: taking an edge away is its own command and needs your approval,
because it is the one change that breaks something and reports nothing.
When Stripe rotates your key,
gg resource rotate shop/stripe --env-file .env.striperolls every service that holds it. No deploy, and no hunt through services for the ones that had a copy. The values are never copied into a service's stored environment either, sogg rollback shop/webputs back yesterday's image and settings and never yesterday's password.
Two habits from Railway to drop here.
Secrets are not deploy arguments. --env-file on a deploy is for a
LOG_LEVEL, and a deploy replaces that file's variables wholesale, so
web.env is committed and restated on each ship. Anything you would mind
seeing in a commit, a shell history or an agent transcript goes in an external
instead, and the graph in the console then shows which services hold it.
Nothing builds on push. Railway watches your branch and builds when it
moves. Gagarin does not build, on purpose, and says why. Your CI
runs gg ship with a credential from gg creds create, which can deploy,
expires, and cannot delete. The secret goes to stdout alone, so it pipes
straight into a CI secret and never touches a disk or a transcript:
gh secret set GAGARIN_TOKEN --body "$(gg creds create --name "github actions: shop" 2>/dev/null)"For this project the workflow itself is about twenty lines.
14:40: The cron job
On Railway the schedule sits in a service's settings. On Gagarin a scheduled
job is a gg run with a schedule. It is a job rather than a service: it runs to
completion, and nothing can reach it.
gg run shop/report report:v1 --deps database --schedule "0 3 * * *" --tz Europe/BerlinNothing runs now, and gg run returns at once. Tomorrow at three, check it with
gg logs shop/report, or open the project in the console at
my.gagarin.cloud, which keeps a feed of how each
firing ended.
Firings never overlap: a report that runs long delays the next one rather than running beside it. A firing that fails pushes one notification to your phone, and the next one that succeeds clears it. And a one-off
gg run, say a migration before a deploy, waits and exits with the script's own exit code, so CI can stop on a failed migration without a line of polling.
14:50: Check it before your users do
$ gg status shop
project shop (id k3qm7ebd)
SERVICE KIND SIZE READY PORT REACHES IMAGE
● web service s 1/1 8080 database, stripe◆, smtp◆ web:1761480212
● └ https://web-k3qm7ebd.apps.gagarin.cloud
● worker service s 1/1 9090 database worker:1761480250
◷ report job s ⏱ 0 3 * * * — database report:v1
└ next in 12h
● database postgres m 1/1 5432 — postgres:17
◆ stripe external — — — — —
◆ smtp external — — — — —
● running ◷ scheduled ◆ external (runs nothing; declaring it grants its variables, not egress)The REACHES column is the firewall, printed. The plain edges open a route
inside the project. The diamond edges hand over a key and open nothing, since
anything in a project can already reach the internet. The worker has no
address because nobody asked for one, and it cannot acquire one by accident.
gg is not the only window onto this. The console at
my.gagarin.cloud draws the same project as a live
graph, with each service's status, logs and history beside it, the bill for
the month so far, and the switch that sends a push to your phone when a
service goes down or the report fails at three in the morning. Everything the
console shows is something gg can read, and the drawings in this post are
the console's drawings.
If something is wrong at this point,
gg statustends to say what. A container killed for memory gets a line naming the size to move to. A crash is ingg logs shop/web, with the crashed container's output kept rather than lost with the pod, and--since 2h --grep "panic:"narrows it. A deploy you regret isgg rollback shop/web, one call, recorded as a new revision so the history keeps the evidence.
The web service already has a generated address. Open it, sign in, and click
through the pages that touch the database. Expect to find one thing of your
own here. Ours was a report that assumed TZ was set, because Railway's base
image set it and our Dockerfile did not. One line in the Dockerfile, one more
gg ship.
15:20: The address
gg domain add shop/web shop.example.comIt prints the DNS record to create. Make the change at your registrar, and the certificate arrives once the record resolves. The generated address keeps working in the meantime, so the service always has a door.
Both addresses answer from then on, and no deploy can ever take either away: an address is a standing declaration, and forgetting a flag will not remove one.
One public address, on the one service a browser opens. The worker, the job and the three resources stay private, and the arrows say who may reach what.
Keep the Railway service running through the DNS change. Lower the TTL on the record the day before if you can. If you did not, some users reach the old service for an hour, and the two services write to two different databases while they do. That hour is the cost of this approach, and the reason the next step is there.
15:40: Freeze, copy the delta, cut over
For a side project, skip this and lose an hour of writes. For anything with users, do it.
- Put the Railway service into maintenance mode, or scale it to zero.
- Dump again and restore again, with the commands from 13:00 and 13:30. The second dump is small.
- Flip the DNS record.
- Watch
gg logs shop/webfor a few minutes.
Budget twenty minutes between the freeze and the flip. Most of it is DNS, and most of that goes away if you lowered the TTL yesterday.
16:30: Keep Railway for a week
Leave the Railway project in place with the service scaled to zero and the database intact. Idle, it costs close to nothing, and it is the only copy you have of the state before the move. Gagarin takes its first nightly backup tonight and keeps fourteen days of them, so by next week you hold a better copy.
Then delete the Railway project. Railway asks you to confirm in the browser. Gagarin, should you ever leave in the other direction, refuses a destructive command until you press a button it emails you, whichever client asked, and your CI credential cannot ask at all.
Even you cannot destroy the database while a service still declares it: the refusal names what depends on it. And when you do go,
gg eject shopwrites the Kubernetes objects the project runs as, so leaving is a file rather than a rewrite. The model makes the case for that design. It is in this post because someone who moved a production database an hour ago cares a great deal about what can delete things.
The afternoon, totalled
| what | time | commands |
|---|---|---|
| inventory, dump | 30 min | railway variables, pg_dump |
| project, database, restore | 40 min | gg init, gg resource add, gg connect |
| variables, two services | 30 min | gg resource add ×2, gg ship ×2 |
| cron job | 10 min | gg run --schedule |
| checks, one fix | 30 min | gg status, gg logs, gg ship |
| address, cutover | 70 min | gg domain add, the delta dump, DNS |
Three and a half hours. One line of code changed, and it fixed a bug you were
already carrying. Two new files in the repository: the CI workflow, and a
.gagarin.json that says which project this is.
You give up builds on push and a second region. You get a graph that is the firewall and the secrets map at once, traffic that costs nothing, and a platform where the agent that runs your deploys cannot remove anything until you press a button.
If you would rather not spend the afternoon yourself, the coding agent you already use can run the same commands. Paste it the Railway project name and a link to this post. It will ask you to press a button once, when it wants to delete the old project.