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
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
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
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
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
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
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/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.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.
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.
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-filerolls every service that holds it. No deploy, and no hunt through
.env.stripe
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/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-offgg 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.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.
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.
- 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.
Sources
Originally published at gagarin.cloud.



Top comments (0)