writing

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.

sh
railway variables --service web --kv > web.env
railway variables --service worker --kv > worker.env

Open 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:

sh
railway connect postgres   # in one terminal
pg_dump "$DATABASE_URL" -Fc -f before.dump   # in another

A 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

sh
gg login
gg init shop
gg resource add shop/database postgres --size m

gg 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:

sh
gg connect shop/database

It 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:

sh
pg_restore --no-owner --no-acl -d "postgres://…@127.0.0.1:5432/…" before.dump

Stop 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/database takes 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:

sh
gg resource add shop/stripe external --env-file .env.stripe
gg resource add shop/smtp external --env-file .env.smtp

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

sh
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.stripe rolls 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, so gg rollback shop/web puts 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:

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

sh
gg run shop/report report:v1 --deps database --schedule "0 3 * * *" --tz Europe/Berlin

Nothing 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 status tends to say what. A container killed for memory gets a line naming the size to move to. A crash is in gg 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 is gg 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

sh
gg domain add shop/web shop.example.com

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

  1. Put the Railway service into maintenance mode, or scale it to zero.
  2. Dump again and restore again, with the commands from 13:00 and 13:30. The second dump is small.
  3. Flip the DNS record.
  4. Watch gg logs shop/web for 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 shop writes 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

whattimecommands
inventory, dump30 minrailway variables, pg_dump
project, database, restore40 mingg init, gg resource add, gg connect
variables, two services30 mingg resource add ×2, gg ship ×2
cron job10 mingg run --schedule
checks, one fix30 mingg status, gg logs, gg ship
address, cutover70 mingg 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.

Sources

  1. Railway CLI
  2. Railway variables
  3. Railway GitHub autodeploys
  4. Gagarin vs Railway