How to migrate a Rails app from Railway to Render
Moving a Ruby on Rails app off Railway is two migrations in one. The stateless part is easy, because your code already lives in Git and Render deploys from the same repo. The stateful part is where migrations go wrong, because your Postgres data needs an explicit export, an explicit import, and a window where nobody is writing to the source.
If you are still deciding whether to move at all, read when to migrate from Railway to Render (and when not to) first. This guide assumes you have already made that call.
From there, Render's migrate from Railway guide covers the platform-level procedure for any stack. This article covers the Rails-specific layer on top of it: the build script Render expects you to write, where database migrations belong in the deploy lifecycle, the environment variables that will crash your app on boot if you forget them, and the handful of Rails conventions that behave differently.
Map your Railway services to Render service types
Railway keeps your services on a project canvas and infers the connections between them. Render asks you to name each component and its dependencies explicitly. That shift is most of the conceptual work, and for a typical Rails app the mapping is short:
What you have on Railway | What you create on Render |
|---|---|
Rails service with a public domain | |
Sidekiq or other queue consumer | Background worker |
Internal-only service that still takes requests | |
Scheduled service | Cron job |
PostgreSQL template | |
Redis template |
Note that Railway's Postgres and Redis are template deployments that Railway's own docs call unmanaged, which means their configuration and maintenance are yours. Render Postgres and Render Key Value are fully managed, with managed workflows for operations such as recovery and PostgreSQL version upgrades. Point-in-time recovery covers every paid Postgres instance, with a recovery window of the past 3 days on Hobby workspaces and the past 7 days on Pro and above, and high availability with automatic failover is available on Pro and Accelerated instance types. If you run Sidekiq inside your web process today, this is also the moment to split it into its own background worker, because Render treats workers as a first-class service type with independent scaling and deploys.
The link between your app and your database also becomes explicit. Set DATABASE_URL to the database's internal connection string, which routes over Render's private network with no internet roundtrip. Reserve the external connection string for clients outside Render, such as your laptop during the data migration. Mixing these two up is easy to miss, because it usually shows up as slow queries rather than an obvious error.
Define the stack in a Blueprint
Render reads infrastructure from a render.yaml file called a Blueprint. Put it in your repository root, connect the repo in the Render Dashboard, and Render provisions the services it describes. You can click all of this together in the dashboard instead, but a Blueprint gives you the same version control over infrastructure that you already have over code, and it makes the Railway-to-Render diff reviewable.
The example below declares the full shape a Rails app usually needs: a web service, a Sidekiq worker, a Key Value instance, and a Postgres database. Four of its fields are the ones Rails apps get wrong. If you include the databases block as in this example, Render creates an empty database and runs your migrations against it. If you're moving production data, there are some additional tradeoffs to consider, as discussed in a later section.
RAILS_MASTER_KEY uses sync: false, which tells Render to prompt you for the value at Blueprint creation instead of reading it from Git. Without this key, an app using encrypted credentials crashes on boot with a decryption error. Every service that boots your Rails code needs it, so the worker declares it too.
WEB_CONCURRENCY needs an explicit value. Left unset, Rails sizes its Puma worker pool from the runtime's physical CPU count, which can exhaust the instance's memory immediately on boot. Start at 2 and tune from there.
region appears on all four resources, and the values match. Co-locating them makes the internal connection strings usable.
Set healthCheckPath to an HTTP path Render can poll. Render routes traffic to a new instance only after this path answers with a 2xx or 3xx status within five seconds. For a service scaled to multiple instances, Render replaces them one at a time. If any new instance fails to become healthy, Render cancels the deploy and leaves the previous version serving.
The fromDatabase and fromService blocks are the payoff for declaring every resource together. Render injects each datastore's internal connection string into the services that reference it at deploy time, so no connection string is ever hardcoded or copied by hand.
The Key Value instance carries three fields that affect Sidekiq. maxmemoryPolicy: noeviction makes the instance reject new writes when it fills up instead of quietly deleting keys, which is what you want for queued jobs. plan: starter is the smallest instance type that writes to disk, because the Free instance type has no persistence. And ipAllowList: [] permits internal connections only, which is correct in steady state but something you will need to loosen briefly if you copy queue data across during the migration.
The instance types above assume you are migrating production. While you rehearse, you can change both the web service and the database to plan: free. You also need to delete preDeployCommand and keep db:migrate in the build script instead, because pre-deploy commands require a paid instance type. Free Render Postgres caps storage at 1 GB, allows one active instance per workspace, takes no backups, and expires 30 days after creation, with a 14-day grace period before deletion. It's a fine place to practice the cutover, but paid instance types are recommended for all production data. Key Value has a Free instance type too, also one per workspace, though it drops every key on restart. No Free instance type is available for background workers.
Carry over the rest of your environment variables
The Blueprint above spells out DATABASE_URL, REDIS_URL, RAILS_MASTER_KEY, and WEB_CONCURRENCY, but every other variable your app reads has to make the trip as well. Open each Railway service's Variables tab and work through the list. Skip the ones Render supplies or the Blueprint already wires up, such as PORT, DATABASE_URL, and REDIS_URL, and add the rest with sync: false if they are secrets and a literal value if they are not.
Any variables that are shared across multiple services, such as credentials used by both your web service and Sidekiq worker, belong in an environment group referenced with fromGroup. Render services do not share environment variables otherwise, and maintaining two copies of the same API key is how they drift.
Write the build script Render expects
Railway infers your build. Render runs the command you give it, and for Rails that command should be a script, because a Rails build is several steps. Create bin/render-build.sh:
Then make it executable and commit that change:
Decide where migrations run
On a paid instance type, put db:migrate in preDeployCommand. It runs after the build and before the new version takes traffic. On the Free instance type, preDeployCommand is unavailable, so uncomment the migration at the end of the build script instead. Running it in both places migrates twice on every deploy.
Long migrations take a different shape. A schema change that rewrites a large table may hold up your deploy, so run it through a one-off job and keep it out of the deploy path entirely. On a paid instance type you can also open an SSH session and run it there.
Move your Postgres data
This is the risky step and the one that defines your downtime window, so the order matters. Deploy the Blueprint first and db:migrate runs against an empty database, building your entire schema plus the schema_migrations and ar_internal_metadata tables Active Record keeps for itself. A full dump restored on top of that fails on objects Rails already created.
There are two ways around this. Create the database on its own first, restore into it, and only then deploy the services, in which case the restored schema_migrations rows tell Active Record there is nothing left to run. A fromDatabase reference can resolve against an existing Postgres instance in your workspace even when the instance is not defined in the same Blueprint, so you can drop the databases block and keep the wiring. Or create everything at once and restore with --clean --if-exists, which drops the objects Rails just added before rebuilding them from the dump. That second route needs the restoring role to own the objects it drops, so connect with the destination database's default owner user rather than a read-only role.
Get the source connection string first: open your Postgres service in the Railway Dashboard, confirm TCP Proxy is enabled, and copy DATABASE_PUBLIC_URL. Then provision the Render database and copy its external connection string from the Connect dropdown on its Info page.
Before you dump anything, stop writes to the source. Railway has no maintenance mode, so the documented approach is to open each service, find the active deployment, and click Remove in its 3-dot menu. This stops the service outright rather than showing a maintenance page, which is why you schedule the cutover for off hours and rehearse it beforehand.
The -F c custom-format dump writes to a file, so a failed restore can be retried without re-querying the source. --no-owner --no-acl strips ownership and permission statements, which is necessary because the dump's Railway role names don't exist on Render and any ALTER ... OWNER TO would otherwise fail. For large databases, add --jobs 4 to pg_restore to parallelize the restore, since both custom and directory formats support parallel restores. To parallelize the dump itself, switch to directory format instead: pg_dump -F d -f railway_backup_dir --jobs 4. Parallel dumping only works with -F d, since it's the only format that allows multiple processes to write data at once.
Four things are cheaper to check now than to discover mid-cutover.
The first is your PostgreSQL client-tool version. Use a pg_dump version that is at least as new as the Railway source server, because pg_dump refuses to read from a newer server. For the import, install the client tools for the Render destination's major version and use their pg_restore. Dump output is expected to load into newer PostgreSQL versions, but it is not guaranteed to load into an older major version. That asymmetry is why upgrades during the move work and downgrades don't. Render supports PostgreSQL 13 through 18 for new databases, and versions 11 and 12 are available only to workspaces already running them, so a Railway database on 12 or older usually has to be upgraded as part of the move rather than matched.
Extensions need the same advance work. Inventory the extensions on Railway and confirm that the destination PostgreSQL version supports each one in Render Postgres. A full pg_dump archive includes CREATE EXTENSION statements, so pg_restore normally enables supported extensions during the import. If you use a data-only dump or exclude extension metadata, enable the required extensions yourself with CREATE EXTENSION before loading the data. Do not pre-create extensions before a full restore unless your restore plan calls for it. Extensions such as PostGIS create their own schemas and tables, including topology and spatial_ref_sys, which can otherwise conflict with objects in the archive.
Size the instance for your current data plus growth, because you can increase Postgres storage only once every 12 hours and you cannot decrease it at all. Turning on storage autoscaling covers the gap, since Render then adds 50% more storage, rounded up to the nearest 5 GB, whenever the instance hits 90% full.
Your Sidekiq queues need a decision rather than a check. Redis holds your enqueued, scheduled, and retry sets, so pointing REDIS_URL at a fresh Key Value instance abandons every job still sitting in them. The simpler option is to let the queues drain before you stop the workers. If you would rather copy the data across with redis-cli, as the platform migration guide describes, note that new Key Value instances are unreachable at their external URL by default. You have to enable external connections with an inbound IP rule first, then remove it once the copy finishes.
Verify the first deploy
Two failures dominate bad first deploys, and both are configuration divergence rather than anything wrong with your app.
The first is a missing RAILS_MASTER_KEY, which crashes the app on boot with a decryption error. The second is a connection problem that looks like a TLS problem. Connections over the external URL are encrypted in transit with Render-managed TLS certificates, and Render requires TLS 1.2 or higher, so a handshake failure from a local client usually means the client is too old rather than that anything is misconfigured on Render.
Once the service is live, exercise the paths that depend on things you carried over by hand rather than through Git: user authentication, background job processing, and outbound mail. Then read the service logs, and look for what is missing rather than what is broken. Failed jobs and boot warnings usually point at an environment variable that never made the trip, such as your Key Value instance's REDIS_URL or a third-party API key.
Only then move your custom domain over. Until DNS points at the Render service, the migration is not finished, and your downtime window stays open for as long as the change takes to propagate.