Skip to main content
This guide moves a PostgreSQL database from Heroku Postgres to an RDS instance managed by Ravion. It assumes your application already runs on AWS — if not, start with Migrate from Heroku to AWS, which gets the app onto AWS before touching the database. There are two approaches. Most teams take the first.

Step 1: Create the RDS instance

Add an rvn-rds module to the environment your app runs in, in the same network module, with engine: postgres. Match the Heroku Postgres major version (heroku pg:info --app my-app shows it) — you can upgrade after the migration. Size the instance from Heroku’s plan: the standard-0 plan is roughly a db.t4g.medium; standard-2 and up map to db.m7g or db.r7g classes.
ravion.yaml
This allows anything inside the VPC, including the pipeline runner that does the restore. To restrict to the app, list the ECS service security group IDs in allowed_security_group_ids instead. Apply the config. The module generates the master password into Secrets Manager; the endpoint and the secret ARN are in the module’s outputs.
RDS for PostgreSQL 15 and later requires TLS by default (rds.force_ssl = 1). Make sure your app’s connection string works with sslmode=require before the cutover — see PostgreSQL SSL connection error.

Step 2: Reach the database

The RDS instance is in a private subnet, so pg_restore has to run from inside the VPC or over a tunnel. Options, from simplest:
  • A pipeline step. Run the dump and restore from a custom step — it runs on an EC2 instance in your VPC with no extra setup. Install postgresql-client in a setup action, pull the Heroku dump URL and the RDS credentials from Secrets Manager, and run the commands below.
  • A bastion or SSM tunnel from your laptop to a small EC2 instance in the VPC.
  • Temporary public access. Set public_access_enabled: true and add your IP to allowed_cidr_blocks on the module, apply, migrate, then revert. Fine for a one-off; do not leave it on.

Step 3: Do a dry run

Run the whole procedure once against a scratch RDS instance before the real thing. You want two numbers: how long the dump takes, and how long the restore takes. Their sum is your maintenance window. A restore of a few tens of GB typically takes 10–30 minutes with parallel jobs.

Step 4: Migrate with a maintenance window

1

Announce the window and stop writes

Scale worker services to zero and put the app in maintenance mode, or stop the web service. Any write that happens after the dump starts is lost, so make sure nothing is writing.
2

Take a fresh backup on Heroku

Heroku produces a pg_dump custom-format archive. Alternatively, get a signed URL with heroku pg:backups:url and curl it from inside the VPC.
3

Restore into RDS

  • PGPASSWORD keeps the password out of the command line and shell history; ~/.pgpass works too.
  • --no-acl --no-owner drops Heroku-specific role grants that do not exist on RDS.
  • --jobs restores tables in parallel; set it to the number of cores on the machine running the restore.
  • Expect warnings about extensions Heroku installs by default that you do not use. Errors about CREATE EXTENSION for extensions you do use mean you need rds_superuser (the master user has it) or the extension is not available on RDS.
4

Verify

Compare row counts on a few large tables between Heroku and RDS. Check that sequences are at the right values (SELECT last_value FROM my_table_id_seq) — a restore sets them correctly, but this is the thing that hurts most when it is wrong. Run any data validation scripts your app has.
5

Point the app at RDS

Update the DATABASE_URL key in your environment secret to the RDS connection string (with ?sslmode=require), then redeploy the web and worker services. Redeploying is required: ECS reads secrets when a task starts.
6

Reopen writes

Take the app out of maintenance mode and scale workers back up. Watch error rates and the RDS metrics tab for CPU, connections, and IOPS.

Zero downtime with dual writes

If you cannot take a window, the pattern is:
  1. Create the RDS instance and do an initial dump and restore as above, without stopping writes.
  2. Change the application to write every mutation to both databases while continuing to read from Heroku. This is application code — an ORM hook or a repository layer — and it has to handle a failed write to one side.
  3. Backfill rows that changed on Heroku between the initial restore and the moment dual writes started (compare updated_at columns, or re-copy the affected tables). updated_at cannot find rows deleted in that gap — use soft deletes, log deletes to a table you replay, or re-copy the affected tables. If your schema cannot support this, use AWS DMS with change data capture (below) instead.
  4. Validate the two databases match, then switch reads to RDS.
  5. Stop writing to Heroku.
An alternative to hand-written dual writes is logical replication with AWS Database Migration Service, using Heroku Postgres as the source. It requires a Heroku plan that exposes logical replication settings and a publicly reachable source, so check both before planning around it.

Afterwards

  • Keep the Heroku database for about a week, on the smallest plan, in case you need to recover anything. Then delete it.
  • Turn on the module’s CloudWatch alarms and review the RDS metrics after a full day of traffic. Adjust instance_class and max_allocated_storage if needed — both are in-place changes.
  • Consider RDS Proxy if your Heroku setup relied on PgBouncer.