Migrating From Heroku Postgres to a Managed Database
Dump-and-restore works for small databases; logical replication handles the big ones.

Heroku Postgres migrations follow a repeatable sequence: size the database, pick dump-and-restore or logical replication based on that size, then cut over the connection string. The harder decision isn't the method, it's where you land. AWS RDS, GCP Cloud SQL, and Azure Database for PostgreSQL all do the job well, and the right answer usually comes down to where the rest of your infrastructure already sits.
Salesforce's 2024 annual report puts Heroku's installed base at roughly 9 million active apps and 13 million developer accounts. That's a lot of teams now deciding what comes next. Heroku itself has said it's in sustaining engineering mode: no new Enterprise Account contracts, and feature work has slowed to a crawl. The 2025 "Fir" runtime, built on Kubernetes and initially targeting ARM (Graviton) before adding AMD64/x86_64 and OCI image support, reads less like a pricing fix and more like an admission that the platform had fallen behind structurally. For most engineering teams reading this, the decision to leave has already been made. Porter, a bring-your-own-cloud PaaS that runs inside your own AWS, GCP, or Azure account, is one destination teams land on after making that call. What's left is doing it cleanly.
What Heroku Postgres actually costs at each tier, and where the pricing cliff hits
Heroku's 2026 pricing looks approachable at the bottom and gets steep fast. Essential-0 runs $5 a month, Essential-1 is $9, Essential-2 is $20. Standard-0, the first tier Heroku actually calls production-grade, jumps to $50 a month. Premium plans start at $200.
That jump from $20 to $50 is where a lot of growing teams get stuck. Essential tier carries a lower uptime SLA, which allows for up to four hours of downtime a month and doesn't support fork-and-follow, meaning no read replicas and no leader-follower setup at all. Standard tier bumps the SLA significantly higher, but the moment a team needs real production uptime or a replica, the bill more than doubles.
And it doesn't stop at the database line item. Add Heroku Redis Mini at $15 a month, a log drain add-on, a scheduler, and a staging environment, and a single small team can land at $200 to $400 a month before hitting any meaningful scale. To be fair, Heroku has made real improvements here: the old Mini and Basic plans with row-count limits that blocked INSERT and UPDATE statements outright are gone, replaced by Aurora-backed Essential plans with no row caps and pgvector support baked in. That's a genuine fix. It just doesn't touch the pricing cliff, and it doesn't touch the structural limits either.
Those limits are worth naming plainly. Essential tier only runs in US East and EU West; Standard and Premium follow wherever your app lives, but Asia-Pacific or Latin America requires Enterprise-level Private Spaces. There's no bring-your-own-cloud option, no VPC-level networking, and observability tools lag behind what AWS, GCP, and Azure offer natively. Connection limits are enforced per plan (120 connections on Standard-0), and while server-side pgBouncer pooling exists, it's not available on Essential-tier or Advanced databases. The extension list is closed too: whatever Heroku has curated is what you get, and you can't touch postgresql.conf directly.
Sizing your database before choosing a migration method
Before picking a migration method, get an honest read on how big the database actually is. This one number decides almost everything downstream.
The rule of thumb: under 100 GB, dump-and-restore is the sane choice. Above that, logical replication earns its complexity.
Don't trust Heroku's UI storage charts for this. Those percentage bars include system files and tend to overstate the real number by a wide margin. Connect with the psql client instead and run \l+ to see the actual size in the Size column. Heroku's own migration docs cite a case where a DigitalOcean-hosted instance showed a very small percentage of a 10 GB disk in use, an alarming-looking figure until you realize the actual database was 56 MB. Disk usage percentage and database size are not the same thing, and conflating them leads to wildly wrong planning.
While you're in there, take inventory of a few other things:
- The Postgres version currently running, since it affects logical replication compatibility and whether a version bump rides along with the move
- Every extension in active use, confirmed against what the target platform actually supports
- Real-world connection counts, not theoretical maximums, so you know whether pooling needs to be configured on day one
- Any worker dynos or background jobs writing to the database, since those need to be paused during cutover
By the end of this step you should have four concrete numbers: database size, Postgres version, an extension list, and a connection ceiling. Those four things decide the method and the target tier.
Dump-and-restore: the right path for databases under 100 GB
This is the right call when the database is under 100 GB and the team can live with a maintenance window measured in hours rather than seconds.
Before touching anything, take a fresh backup of the source. Confirm a recent backup exists before you start. Put the app into maintenance mode, scale down any worker dynos that touch the database, and either flip the source to read-only or take dependent services offline. Anything written during the dump window gets lost at cutover, so tell your users about the window in advance.
Dump the database with pg_dump:
pg_dump postgres://DB_USERNAME:DB_PASSWORD@DB_HOST:DB_PORT/DB_NAME -Fc -b -v -f /tmp/data-for-migration.dump
The -Fc flag produces a custom-format archive built for pg_restore, -b includes large objects, and -v gives you verbose output so you can actually watch progress instead of staring at a blinking cursor. How long this takes depends entirely on database size, so plan conservatively and don't schedule the window too tight.
Once the dump file exists, get it somewhere the target can read it. For AWS RDS, that means an S3 bucket in the same region as the target instance, restored via a short-lived signed URL, never a public bucket. Heroku's own Aiven migration guide is explicit about this: sign the URL, don't open the bucket. For GCP Cloud SQL, a GCS bucket does the same job. For Azure Database for PostgreSQL, use Azure Storage as the staging area.
One performance note worth taking seriously: AWS's own PostgreSQL migration guidance recommends running pg_dump from a host in the same Availability Zone as the target RDS instance, with strong CPU and network throughput. An EC2 instance staged in that AZ cuts down network latency noticeably and speeds the whole transfer.
Restore with pg_restore:
pg_restore -h TARGET_HOST -U TARGET_USER -d TARGET_DB -Fc /tmp/data-for-migration.dump
If the target user differs from the source owner, which is common when moving to a cloud-managed instance with its own superuser roles, consult the target platform's documentation on ownership and permission flags for managed instances.
Before flipping the switch, verify everything landed correctly. Run \l+ on the target and check the size matches what you expect. Spot-check row counts on a handful of critical tables. Run the application's test suite, or at minimum a smoke test, pointed at the new database. Only then update DATABASE_URL, restart the app, and bring it out of maintenance mode.
Logical replication: near-zero downtime for larger or availability-sensitive databases
Above 100 GB, or when downtime needs to be measured in seconds instead of hours, dump-and-restore stops being practical. Logical replication is the answer.
Logical replication streams changes at the SQL level instead of copying raw disk blocks, which is exactly what lets you replicate across different Postgres versions. Physical replication requires filesystem access or superuser-level streaming replication, neither of which Heroku exposes. So logical replication isn't just the faster option here, it's the only one that actually works against Heroku Postgres. Published benchmarks bear out the tradeoff: pg_dump and restore costs hours of downtime, logical replication costs seconds.
Setting it up means building a publication-subscription pair. On the Heroku side, create a publication:
CREATE PUBLICATION db_migration_publication FOR ALL TABLES;
Before this works, confirm Heroku's wal_level is set to logical. This is a hard requirement for publications, and so check early rather than discovering the problem mid-migration. On the target, whether that's RDS, Cloud SQL, or Azure, create a subscription pointing back at the Heroku connection string. The initial table sync kicks off automatically, and from there it's a matter of watching replication lag until the target catches up to the source. The pglogical extension, or the logical replication features built into Postgres 10 and later, make this setup easier to manage.
Once lag hits zero, the cutover itself is quick:
- Pause writes on the source (maintenance mode or read-only)
- Confirm lag has actually reached zero on the target, not just close to it
- Update DATABASE_URL to the new host
- Drop the subscription on the target and the publication on the source
- Bring the app back online
Total write-pause time here lands in seconds, not hours. That's the entire appeal of this method.
One catch that trips people up: logical replication does not carry sequences over. After cutover, manually set sequence values on the target, or you'll run into primary key collisions the first time a new row gets inserted. Large objects and any DDL changes made during the replication window also need manual handling, so keep a running note of schema changes made while replication is active.
Choosing the right target: AWS RDS, GCP Cloud SQL, or Azure Database for PostgreSQL
The governing rule here is simple: put the database in the same cloud, and ideally the same region, as the application compute. That single decision kills cross-cloud egress fees and cuts connection latency more than any tuning you'll do afterward.
AWS RDS for PostgreSQL or Aurora PostgreSQL is the natural fit if the app already runs on EC2, ECS, EKS, or Lambda. Aurora PostgreSQL is compatible with standard Postgres tooling, so check extension compatibility before committing. AWS's own guidance recommends staging the pg_dump and restore process through an EC2 instance in the same Availability Zone as the target, to keep network latency down during transfer. RDS supports logical replication as a subscriber, and the wal_level and related settings can be adjusted through parameter groups, no filesystem access required. The tooling around automated backups, Multi-AZ failover, read replicas, and Performance Insights is mature.
GCP Cloud SQL for PostgreSQL makes sense for teams on GKE, Cloud Run, or App Engine. Heroku's own Dev Center actually documents the reverse migration, Cloud SQL into Heroku, using the same dump-and-restore tooling with GCS as the staging bucket, which tells you the path works cleanly in both directions. Cloud SQL supports logical replication subscriptions, though enabling logical decoding is done through the Cloud SQL flags interface rather than a direct wal_level setting. Private IP connectivity plugs in cleanly with VPC-native GKE clusters.
Azure Database for PostgreSQL, specifically the Flexible Server offering, fits teams on AKS or Azure App Service. Heroku's Dev Center documents this migration path too, with Azure Storage blob containers standing in as the staging area. Make sure you're targeting Flexible Server specifically, not the older Single Server offering, which is being retired. Flexible Server supports logical replication, and integration with Azure Private Link and VNet injection covers most compliance requirements teams run into.
All three give you managed backups, automatic minor-version patching, read replica support, and network isolation at the VPC or VNet level — capabilities that Heroku's Essential and Standard tiers restrict or do not offer.
Connection string cutover and what to update beyond DATABASE_URL
The obvious change is updating DATABASE_URL, or whatever your framework calls the equivalent environment variable, to the new connection string in the format postgres://USER:PASSWORD@HOST:PORT/DBNAME. But that's rarely the only thing that needs updating, and teams that stop there tend to get paged later.
SSL is the first trap. Cloud-managed Postgres instances enforce SSL by default, so the connection string needs ?sslmode=require, or verify-full if you want stricter certificate validation. Heroku Postgres enforced SSL too, so this isn't new behavior, but the certificate authority is different on the new host. If the application validates the server certificate against a bundled CA list, that bundle needs updating or connections will fail outright.
Connection pooling is the second trap. Heroku's Standard-0 plan caps out at 120 connections, and cloud-managed instances tie their own limits to instance size, not to a flat plan number. That means the pooling configuration that worked fine on Heroku might be wrong for the new instance size, in either direction. Check the actual connection ceiling on the target tier before assuming the old settings carry over, and adjust pool size accordingly rather than guessing.


