Nov 11, 20267 min read

How to Migrate Railway Postgres to Amazon RDS

Move a Railway Postgres database into Amazon RDS in your own AWS account through Railway's public TCP proxy with pg_dump and pg_restore, with a rehearsal, a short write freeze and checks before cutover.

Illustration for How to Migrate Railway Postgres to Amazon RDS

Railway makes Postgres a one-click service next to the app, with the app reading it through a ${{Postgres.DATABASE_URL}} variable reference and the data living on a volume attached to that service. The reasons to move it tend to arrive together: a second backend needs to reach the database from AWS, the team wants point-in-time recovery and read replicas it controls, or AWS credits are paying for everything except the database.

This guide moves a Railway Postgres database into Amazon RDS in your own AWS account with pg_dump and pg_restore. The RDS side is set up with Encore, which provisions it from the database declared in your TypeScript code, with the hand-built setup covered as an alternative.

The migration at a glance

StepWhat happensWrites on Railway
1. Open a path to the dataEnable the TCP proxy, read DATABASE_PUBLIC_URLAllowed
2. Stand up RDSEncore deploy, or Terraform and the consoleAllowed
3. RehearseData-only dump through the proxy, restore into RDSAllowed
4. FreezeScale the app service and workers on Railway to zeroStopped
5. Final copy and checksRepeat the dump and restore, compare counts and sequencesStopped
6. Cut overPoint traffic at the AWS deploymentResume on RDS

Step 1: Open a path to the Railway data

The DATABASE_URL that the app uses points at Railway's private network and does not resolve from your laptop or from AWS. Enabling public networking on the Postgres service adds a TCP proxy and a DATABASE_PUBLIC_URL variable with a proxy host and port, and that URL is what every command below reads. Traffic through the proxy counts as egress on the Railway bill, so dump once for the rehearsal and once for the cutover rather than in a loop.

Connect with psql "$DATABASE_PUBLIC_URL" and record what the target has to support:

SELECT version(); SELECT extname, extversion FROM pg_extension ORDER BY extname; SELECT pg_size_pretty(pg_database_size(current_database()));

The RDS major version must be the same or newer, every extension must be on the RDS for PostgreSQL extension list, and the size gives a first estimate of the freeze. A pg_dump older than the server refuses to run, so install a client that matches or exceeds version().

Step 2: Stand up RDS with Encore

Encore

Encore brings infrastructure from code to TypeScript and Go: the database the Railway app used becomes a SQLDatabase in the TypeScript service that queries it, and the Encore platform provisions and deploys into your own AWS or GCP account. Deploying that service to an AWS environment creates RDS for PostgreSQL in private subnets with encryption at rest, automated backups and a database role for each service that uses it, as the AWS infrastructure docs list.

Application code passes through Encore and infrastructure guardrails to resources provisioned in your own AWS or GCP account.
Encore provisions the infrastructure your application needs in your own AWS or GCP account.

Set up the app

Get started
brew install encoredev/tap/encore &&
encore app create
Copy

Run in your terminal to get started locally.

~
$ encore app create projects-api --example=ts/hello-world$ cd projects-api && mkdir -p projects/migrations$ pg_dump "$DATABASE_PUBLIC_URL" --schema-only --no-owner --no-acl > projects/migrations/1_bootstrap.up.sql

The schema-only dump becomes the first migration, the pattern the RDS import docs describe for bringing an existing schema along, so every new environment and every laptop builds the same tables. Remove CREATE EXTENSION lines for anything you have dropped, and keep the rest. The service then declares the database next to the endpoints that read it:

// projects/projects.ts import { api, APIError } from "encore.dev/api"; import { SQLDatabase } from "encore.dev/storage/sqldb"; // Postgres locally with `encore run`, Amazon RDS when deployed to AWS. export const db = new SQLDatabase("app", { migrations: "./migrations" }); interface Project { id: number; name: string; ownerId: number; } export const get = api( { expose: true, method: "GET", path: "/projects/:id" }, async ({ id }: { id: number }): Promise<Project> => { const row = await db.queryRow<Project>` SELECT id, name, owner_id AS "ownerId" FROM projects WHERE id = ${id} `; if (!row) throw APIError.notFound("project not found"); return row; }, ); export const rename = api( { expose: true, method: "PATCH", path: "/projects/:id" }, async ({ id, name }: { id: number; name: string }): Promise<Project> => { const row = await db.queryRow<Project>` UPDATE projects SET name = ${name} WHERE id = ${id} RETURNING id, name, owner_id AS "ownerId" `; if (!row) throw APIError.notFound("project not found"); return row; }, );

Deploy to a production environment on AWS, and the RDS instance comes up with the bootstrap schema and no public endpoint. The CLI opens a tunnel to it from your machine, and a second terminal prints a temporary connection string for the restore:

~/projects-api
$ encore db proxy --env=production$ encore db conn-uri app --env=production

Both commands are described in the database docs, and locally encore run starts Postgres with the same bootstrap schema applied, so development moves off the shared Railway database at the same time as production does, and a coding agent that adds a column writes the next numbered migration and tests it against that local database before any deploy. Production databases created this way have deletion protection turned on in AWS, per the infrastructure management docs.

Best fit: teams leaving Railway who want the database in RDS in their own AWS account, created from the code that queries it, with the API and its workers moving in the same step.

Stand up RDS by hand instead

The hand-built target is an RDS instance in a DB subnet group across two private subnets, a security group that only admits 5432 from the application's security group, a parameter group for the major version and a master password kept in Secrets Manager. The instance is private, so the restore has to run from inside the VPC, typically through an SSM Session Manager port forward to a small EC2 host, and the RDS with Terraform guide covers the resources. In that case restore schema and data together instead of data only.

Step 3: Rehearse while Railway keeps serving

Copy the data once with the app still live to learn how long it takes and what breaks:

~
$ pg_dump "$DATABASE_PUBLIC_URL" --data-only --format=custom --no-owner --no-acl -f railway.dump$ [pg_restore](https://www.postgresql.org/docs/current/app-pgrestore.html) --data-only --no-owner --no-acl --jobs=4 -d "$RDS_URL" railway.dump

$RDS_URL is the encore db conn-uri output while the proxy runs. --format=custom lets pg_restore load tables in parallel with --jobs, and --no-owner --no-acl leaves out Railway's role names, which do not exist on RDS. If pg_dump warns about circular foreign keys in a data-only dump, drop and re-create those constraints around the restore, as the pg_dump documentation explains. Empty the target with TRUNCATE ... RESTART IDENTITY CASCADE before the final run.

Steps 4 and 5: Freeze, copy and check

Scale the Railway app service, any worker service and any cron service to zero replicas so nothing writes during the copy, then run the same two commands again. Compare both sides before letting traffic back in:

-- Exact counts for the tables that matter, on both databases. SELECT 'projects' AS t, count(*) FROM projects UNION ALL SELECT 'members', count(*) FROM members; -- Sequences must be at or past the highest id, or the first insert fails. SELECT last_value FROM projects_id_seq; SELECT max(id) FROM projects;

pg_dump includes sequence values in a data-only dump, so a sequence behind max(id) usually means the dump started before the last write, which is the reason for the freeze.

Step 6: Cut over

With Encore, the API in the AWS environment already reads the RDS database, so the cutover is pointing DNS or the frontend's API URL at the Encore environment and leaving the Railway services at zero. With a hand-built setup, set the new DATABASE_URL on the AWS service and restart it. Disable the TCP proxy on Railway once the copy is done, and keep the Railway database around read-only for a few days until RDS has taken its first automated backups.

Frequently asked questions

How do I export a Railway Postgres database?

Enable public networking on the Postgres service so Railway exposes a TCP proxy, copy the DATABASE_PUBLIC_URL variable, and run pg_dump --format=custom --no-owner --no-acl against it with a pg_dump client at least as new as the server. The internal DATABASE_URL only resolves inside Railway's private network.

How do I create the RDS target for a Railway migration with Encore?

Declare the database in TypeScript with new SQLDatabase("app", { migrations: "./migrations" }), start the migrations folder with a schema-only dump of the Railway database, and deploy to an AWS environment. Then run encore db proxy --env=production and restore the data into the connection string from encore db conn-uri app --env=production.

How much downtime does a Railway to RDS migration need?

Writes have to stop for one final dump and restore plus your checks. Time the rehearsal: for a database of a few gigabytes that is usually minutes, so a single maintenance window is enough without setting up replication.

Deploy to your cloud on AWS and Google Cloud
without managing infrastructure

Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.

~/orders — claude
Claude Code
Claude Code v2.1.180Opus 5.5~/orders