Moved a production Rails API from Render to Railway recently. Web service, Solid Queue worker, Postgres 18. Here are the lessons worth stealing, whichever direction you're migrating.
Suspend the old services. Do not delete them.
Suspending costs nothing and it is the entire rollback plan. On cutover night we suspended the old worker, then the old web service, and left both sitting there. If the restore had gone badly we could have brought them back in seconds.
The part people get wrong is when that plan expires. The rollback is valid right up until the new database takes its first write. After that it is void, and restarting the old host actively makes things worse, because now writes are split across two databases and you have to reconcile them by hand. From the first write onward you roll forward and you fix problems where the traffic already is.
Knowing exactly where that line sits is what lets you move fast before it and stop hesitating after it.
Migrate your OAuth config weeks before your infrastructure
A cutover that touches auth config is a cutover that breaks. We normalized every redirect URI to our own domain well ahead of time, so cutover night touched zero OAuth config.
Our plan doc's list of which platforms needed this was wrong in both directions. We assumed X, Instagram and TikTok were already on the canonical domain. Only TikTok was, and X is our highest-traffic connect path. Meanwhile Threads, Pinterest and YouTube were not on the list at all. Grep the live environment export, not the plan doc.
Verify OAuth by connecting, not by reading config
A redirect URI that looks right in a dashboard proves nothing. We ran a real connect on all 11 platforms and watched the nonce rows get created and consumed.
That is how we found a caching bug in our own registration service: it cached a client per instance domain and short-circuited whenever a cached ID and secret existed, without comparing the cached redirect URI to the requested one. It built the authorize URL from the new env var while sending the old client ID. We also found a Facebook scope error that had been quietly broken for three weeks.
Only one worker can exist at a time if you use rotating refresh tokens
X and Bluesky issue a new refresh token on every use and kill the old one. Two workers polling the same account means one silently invalidates the other's credentials, and the account starts failing to publish with no obvious cause.
This 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. We deployed the new worker once, confirmed it booted with a supervisor, dispatcher, three workers and a scheduler enumerating all 27 recurring tasks, then removed the deployment and disconnected the repo so nothing could auto-deploy it back on the next merge.
Prove env parity by hashing, not by eyeballing
123 environment variables. Eyeballing that is not verification, and the failure mode is nasty: one wrong byte in a secret produces an auth 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, and we nearly reproduced those quotes byte-for-byte in production. Pulling the same variables from the platform API showed no quotes. They were an export formatting artifact.
We generated a sha256 prefix, length and whitespace flags for every value and diffed that manifest against the destination's own JSON output. Two values genuinely were byte-sensitive: a multiline PEM key and a secret with a meaningful trailing newline. Exactly the ones a careless copy-paste destroys, and exactly the ones a hash comparison catches.
Restore into an empty schema
pg_restore --clean against a pre-provisioned schema died on dependency-ordered drops, complaining about multiple primary keys on one table. What worked: DROP SCHEMA public CASCADE, recreate it, then a plain pg_restore with no --clean. Verify against row counts captured before the suspend.
The db:prepare trap in Rails multi-database setups
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. The deploy goes green, then seeding crashes the first time it enqueues a job because no solid_queue tables exist.
Three traps sit inside the fix. Chaining 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 if your host's redeploy replays a frozen config snapshot, a pre-deploy-based fix never executes at all.
What worked was SSHing into the running container and running the schema load directly, verifying with an actual SolidQueue::Job.count query returning 0 rather than an exception. We reverted the pre-deploy command back to plain db:prepare afterward, because a schema load is destructive and must never run against restored production data.
Threads do not survive SolidQueue's fork
We ship logs with a background flusher thread started once in an initializer. That initializer runs in the SolidQueue supervisor process. SolidQueue then forks its worker and dispatcher children, and threads do not survive fork. Each child inherited a handle pointing at a dead thread, the ||= never respawned it, and nothing ever flushed.
Logs still teed to stdout, so the host's log view looked completely normal. The only flush that ever fired was the inherited at_exit on process exit, dumping hundreds of buffered lines at once with ingestion-time timestamps. Signature: bursts of identical-timestamp events at restart moments, silence in between. Ours had been dead for days.
A migration is not done when traffic moves
Point-in-time recovery sat above our plan tier, so we built a nightly pg_dump to a private object-storage bucket. Then we actually restored from it into a scratch database on the same instance and compared row counts against live before trusting it.
One packaging trap: Debian's stock postgresql-client is pg_dump 15, and 15 hard-refuses to dump a Postgres 18 server. You need postgresql-client-18 from PGDG, 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 did not delete the old host until a clean week of monitoring said the new one was holding.
Full writeup with the cutover sequence and the rest of the detail: https://xreplyai.com/blog/render-to-railway-migration-guide
Top comments (1)
I particularly appreciated the emphasis on verifying OAuth configuration by connecting, rather than just reading the config, as this approach helped uncover a caching bug in the registration service. The technique of running a real connect on all platforms to test the nonce rows is a great example of thorough testing. I've also had issues with environment variable parity in the past, and using a hash comparison to verify env parity is a clever approach - did you consider automating this process as part of your deployment pipeline to ensure consistency across environments?