Strategy

Render to Railway Migration: 33 Minutes

By @_JohnBuilds_··9 min read
Dark mode infrastructure diagram showing a Rails web service, background worker, and Postgres database moving between two hosting platforms, with a 33 minute cutover window marked on a timeline
We moved a production Rails API from Render to Railway in a 33-minute window with zero OAuth breakage. The trick was doing the risky configuration work weeks earlier, so cutover night touched nothing but DNS and data.

Some context on what was at risk. XreplyAI plans, previews, and publishes across 15 platforms from one calendar, so a queue that stops firing means customers silently miss their posting schedule. On migration night that queue held 1,558 scheduled posts belonging to 958 users across 191 connected social accounts.

The Next.js frontend stayed on Vercel and never moved. Our Render to Railway migration covered three things: the Rails API web service, the Solid Queue worker, and a Postgres 18 database.

Everything below is first-hand. There are no external statistics to cite here, and no benchmarks borrowed from someone else's writeup. Every number in this post came out of our own migration log.

Why migrate your OAuth config weeks before your infrastructure?

In short: a cutover that touches auth config is a cutover that breaks. Every OAuth redirect URI should already point at a domain you own before you move a single service.

Our backend was reachable at two addresses: the Render-assigned onrender.com hostname, and our own api.xreplyai.com. Some OAuth apps were registered against the first one. Those registrations live in twelve different developer portals, and each one needs a human clicking through a console.

So we pulled that work forward. Weeks before cutover, we normalized every redirect URI to the canonical domain, edited the portals, flipped the environment variables, and confirmed each platform still worked. Cutover night then touched zero OAuth configuration.

The plan document said this would be a short list. The plan document was wrong in both directions.

We had assumed X, Instagram, and TikTok were already on the canonical domain. Only TikTok was. X is our single highest-traffic connect path, so the premise that the riskiest platforms were already done was exactly backwards.

The errors ran the other way too. Threads, Pinterest, and YouTube were not on the plan's list at all, while Discord and Slack were flagged as likely problems and turned out to be fine.

The fix: grep the live environment export, not the plan doc. That produced a definitive list of 12 variables across 11 platforms, and that list is what we executed against.

Verify by connecting, not by reading config

A redirect URI that looks right in a dashboard tells you nothing. We verified all 11 platforms the only way that counts: by running a real OAuth connect and watching the database.

Our app writes an oauth_nonces row when a connect starts and consumes it when the callback lands. A created-then-consumed pair a few seconds apart proves the whole round trip works. Bluesky went from created to consumed in 8 seconds. LinkedIn took two failed attempts before the third succeeded, which we would never have known from staring at config.

Mastodon failed and exposed a real bug in our own code. Our registration service caches a client per instance domain and short-circuits whenever a cached client ID and secret exist, without comparing the cached redirect URI to the one being requested. So it built the authorize URL from the new environment variable while sending the old client ID, and Mastodon rejected it. Deleting the stale cache row fixed it, and the row re-registered automatically.

Facebook failed too, and it was worth catching. Meta returned an invalid-scope error at the authorize step, before any callback. That was a scope problem rather than a URI problem, and it had been quietly broken for three weeks.

In short: the only proof an OAuth path works is a live connect that writes a token. Config review finds nothing.

The single-writer rule for rotating tokens

In short: platforms that rotate refresh tokens allow exactly one writer. If your old and new background workers run at the same time, one of them will invalidate the other's credentials.

X and Bluesky hand back a new refresh token every time you use the old one. The previous token dies on use. Two workers polling the same social account means the second refresh silently invalidates the copy the first one holds, and the account starts failing to publish with no obvious cause.

Token rotation is what shapes a parallel-run migration. A second web service alongside the old one is harmless, because reads do not rotate anything. A second worker is not, so only one can exist at a time.

So we deployed the Railway worker exactly once, confirmed it booted with a supervisor, a dispatcher, three workers, and a scheduler enumerating all 27 recurring tasks, then tore it down. Not paused: the deployment was removed and the GitHub repository was disconnected from the service entirely, so nothing could auto-deploy it back to life on the next merge.

Reconnecting the repository and deploying the worker became an explicit numbered step inside the cutover window, after the old worker was already suspended.

How do you prove environment parity between two hosts?

In short: pull values from the source platform's API, hash each one, and diff the hashes against the destination. Dashboard exports are a formatted view, not ground truth.

We had 123 environment variables to move. Eyeballing that list is not verification, and the failure mode is nasty: a single wrong byte in a secret produces an authentication error days later that looks like something else entirely.

Our first pass used a dashboard .env export and it lied to us. Three secrets appeared to carry literal double quotes around their values, and we spent real time planning to reproduce those quotes byte-for-byte on the new host.

Then we pulled the same variables from the platform API and the quotes were not there. They were an artifact of the export format, and we had almost written them into production secrets.

Whatever your source platform's API returns is what your app receives at runtime. Export files are a rendering for humans, and renderings add characters.

We generated a sha256 prefix, a length, and whitespace flags for every value, then diffed that manifest against the destination's own JSON output. Zero mismatches, zero missing, both services carrying identical variable sets.

Two values genuinely were byte-sensitive: a multiline PEM private key, and a secret with a meaningful trailing newline. Those are exactly the ones a careless copy-paste destroys, and exactly the ones a hash comparison catches.

Which Railway gotchas cost the most debugging time?

In short: a healthcheck that probes the wrong port, a redeploy that replays frozen config, blocked outbound SMTP, and a database restart that black-holes the next deploy.

Railway's deploy healthcheck probes $PORT, not your domain's target port. We run Thruster on port 80, proxying to Puma on 3000, with the domain correctly pointed at 80. The healthcheck ignored that and probed whatever PORT said, and Thruster does not set the child process's PORT. Two 300-second timeouts later, setting PORT=3000 fixed it.

railway redeploy replays a frozen configuration snapshot. Environment variables re-render, but configuration changes such as a new pre-deploy command do not apply. We changed a pre-deploy step, redeployed, watched it go green, and the fix had never run. SSH into the running container instead when you need a one-off command executed.

Railway blocks outbound SMTP below the Pro plan. Every mail job died with a connection timeout after cutover. The fix was moving mail to Mailgun's HTTP API. We retried 17 backed-up jobs, 9 delivered, and the remaining 8 failed on invalid recipient addresses, which is a data problem rather than a transport one.

Changing a database service variable restarts Postgres. We rotated credentials, then launched a deploy about 60 seconds later. It hung indefinitely on the internal DNS switchover, booted but silent. Leave roughly two minutes between any database restart and a dependent deploy.

The fresh-database trap in Rails multi-database setups

In short: when two logical databases live on one physical Postgres instance, db:prepare creates the first and then treats the second as already existing, so its schema never loads.

Our app runs a primary database and a Solid Queue database. On a fresh provision, db:prepare creates the physical database through the primary configuration, then checks the queue configuration, finds a database there, and skips loading the queue schema entirely.

The deploy goes green. Then seeding crashes the moment it tries to enqueue a job, because none of the Solid Queue tables exist.

Three traps sit inside the fix. Chaining rake tasks such as db:prepare db:schema:load:queue silently no-ops, because rake invokes a task once per run and the prerequisite already ran. A production schema:load refuses to run without an explicit environment-check override. And the redeploy behavior above means a pre-deploy-based fix never executes at all.

What worked was SSHing into the running web container and executing the schema load directly, then verifying with a job count query. We reverted the pre-deploy command back to plain db:prepare, because a schema load is destructive and must never run against restored production data.

What happens inside a 33-minute cutover window?

In short: suspend the old services rather than deleting them, dump and restore into an empty schema, flip DNS, then roll forward only.

Preparation the day before did most of the work. We dropped the CNAME's TTL to 60 seconds and pre-verified domain ownership with a TXT record, so the certificate could issue the moment traffic moved.

The window ran from 00:52 to 01:25 UTC, around 9pm Eastern, during a quiet traffic band. First the old worker was suspended, then the old web service. Suspended, not deleted: that is the entire rollback plan, and it costs nothing to keep.

The final dump took 6.2 seconds and produced 4.4MB. Then the restore failed. We ran pg_restore --clean against a pre-provisioned schema and it died on dependency-ordered drops, complaining about multiple primary keys on one table.

The recovery that worked: DROP SCHEMA public CASCADE, recreate it, then a plain pg_restore with no --clean flag. That finished in 87 seconds with zero errors. Restore into an empty schema from the start.

We verified against row counts captured before the suspend: 958 users, 191 social accounts, 1,558 scheduled posts, matching exactly. The CNAME flip propagated in under 60 seconds. The TLS certificate then stalled in validation for about 10 minutes. The remedy: delete the custom domain and re-add it, which forces immediate re-validation and issuance.

After that flip and the first writes on the new database, the rollback plan is void. Restarting the old host would split writes across two databases. From that point you roll forward, and you fix problems where the traffic already is.

Hardening the week after: backups you have actually restored

A migration is not finished when traffic moves. Railway's point-in-time recovery sits on a higher plan tier than we pay for, so we built a nightly pg_dump that uploads to a private Cloudflare R2 bucket, kept separate from the public media bucket, with newest-10 and 30-day retention. The first live run wrote 4.6MB in 1.9 seconds.

Then we restored it. We downloaded the dump inside the worker container, restored it into a scratch database on the same Postgres instance, and compared row counts against live. They matched exactly. Then we dropped the scratch database.

Debian's stock postgresql-client package is pg_dump 15, and version 15 hard-refuses to dump a Postgres 18 server. You need postgresql-client-18 from the PGDG repository, and you must derive the Debian codename dynamically. Our Ruby base image moved to trixie underneath us, so a hardcoded codename would have broken the build.

We also opened a database TCP proxy for the restore and closed it afterward. Verify that closure with a real Postgres handshake, not a port check. The shared load balancer edge still accepts raw TCP connections after the proxy is deleted, so a port scanner reports the port open when the database is genuinely unreachable.

Log-based monitors came last: heartbeat absence per service, error-rate spikes, and backup continuity. One trap: our observability pipeline remaps a JSON key named status, so it never becomes a searchable attribute. Monitor on a payload-unique attribute instead.

Two days after the cutover the verification window closed clean: no new error-level logs on either service, two nightly backups landing on schedule, and the queue publishing normally. We deleted the old web service, the old worker, and finally the old database. The rollback plan expired on purpose. What replaces it is the nightly dump we had already restored from once, which is a different shape of safety net and the reason we built it before deleting anything.

The 33-minute window was bought by moving every risky decision out of it: the OAuth portals weeks earlier, the environment parity check days earlier, the DNS TTL drop the night before.

Two rules are worth stealing from this Render to Railway migration. Migrate your OAuth configuration weeks before you migrate your infrastructure, because a cutover that touches auth config is a cutover that breaks. And suspend rather than delete, then roll forward only once the new database takes its first write.

The old host is gone now, deleted once a clean week of monitoring said the new one was holding. Every figure here came from our own log rather than a benchmark: 123 environment variables, 12 redirect URIs, a 6.2-second dump, an 87-second restore, and 958 users who did not notice.

If you want the product those 958 users were on: XreplyAI plans, previews, and publishes across 15 platforms from one calendar, with AI as an optional Pro assist trained on your own archive. Try XreplyAI (10-day trial, no card needed), or read more of our building-in-public writeups on how social media tools drive SEO and staying visible on social while building.

Get X growth tips in your inbox

FAQ

How long should a Rails production migration window be?
Ours ran 33 minutes end to end, including a failed restore attempt and recovery. The dump and restore themselves took about 93 seconds combined. Almost all the time went to verification steps, which is where it should go.
Do I need to change OAuth redirect URIs when I migrate hosts?
Only if they point at a host-assigned domain. Move them to your own domain before migrating, then the migration touches no auth config at all. We normalized 12 redirect URIs across 11 platforms weeks ahead of cutover night.
Why can't I run the old and new background workers at the same time?
Platforms that rotate refresh tokens, such as X and Bluesky, invalidate the previous token on every refresh. Two workers means one silently destroys the other's credentials. Deploy the new worker only after suspending the old one.
Is a dashboard .env export safe to use for migrating secrets?
No. Our export added quote characters that were not in the real values, which nearly caused us to write wrong secrets to the new host. Pull values from the platform API instead, and verify with per-value hashes.
Why did pg_restore --clean fail on a fresh database?
The target had a pre-provisioned schema from a prior deploy, and the clean flag drops objects in an order that hits dependency errors. Drop and recreate the public schema first, then run a plain pg_restore into the empty schema.
What is a safe rollback plan for a host migration?
Suspend the old services rather than deleting them, so restarting them is one click. That plan is valid only until DNS flips and the new database takes its first writes. After that, roll forward and never restart the old host.
Why is my Railway deploy healthcheck timing out?
The deploy healthcheck probes the port in the PORT environment variable, not your domain's target port. If a proxy such as Thruster fronts your app server, set PORT to the app server's port so the healthcheck reaches it.
How much does Railway cost compared to Render?
Our estimate for the equivalent stack on Railway Hobby is roughly 13 dollars per month: one web service, one worker, and one Postgres instance. That figure is an estimate pending a full billing cycle, not a measured invoice.