DEV Community

Viktor Rozenko
Viktor Rozenko

Posted on Originally published at gagarin.cloud

Leaving Railway in an afternoon

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.env
Enter fullscreen mode Exit fullscreen mode

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:

railway connect postgres   # in one terminal
pg_dump "$DATABASE_URL" -Fc -f before.dump   # in another
Enter fullscreen mode Exit fullscreen mode

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

gg login
gg init shop
gg resource add shop/database postgres --size m
Enter fullscreen mode Exit fullscreen mode

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.

after gg resource add

after gg resource add

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/database
Enter fullscreen mode Exit fullscreen mode

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:

pg_restore --no-owner --no-acl -d "postgres://…@127.0.0.1:5432/…" before.dump
Enter fullscreen mode Exit fullscreen mode

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:

gg resource add shop/stripe external --env-file .env.stripe
gg resource add shop/smtp external --env-file .env.smtp
Enter fullscreen mode Exit fullscreen mode

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.

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
Enter fullscreen mode Exit fullscreen mode

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

after the two ships

after the two ships

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:

gh secret set GAGARIN_TOKEN --body "$(gg creds create --name "github actions: shop" 2>/dev/null)"
Enter fullscreen mode Exit fullscreen mode

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/Berlin
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

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

gg domain add shop/web shop.example.com
Enter fullscreen mode Exit fullscreen mode

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.

the whole project, with an address

the whole project, with an address

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

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.

Sources


Originally published at gagarin.cloud.

Top comments (0)