Est.

Migrating Postgres from Heroku to a Managed Cloud Database

A step-by-step process for safely migrating Postgres databases off Heroku's declining platform.

Editor at Large · · 10 min read
PaaS Migration Guides · August 4, 2026 · 10 min read · 2,235 words

Migrating Postgres off Heroku is a well-defined, low-risk operation when teams follow a structured sequence: capture a clean backup, provision and validate the target, and cut over with a clear rollback path. The process does not require dedicated DevOps expertise. It requires discipline and sequencing. What follows is how you actually do it.

But first, the honest context. Heroku isn't broken. Teams aren't fleeing a disaster. They're outgrowing a platform that stopped growing with them — like a favorite pair of jeans that fit perfectly in college but haven't seen the inside of a waistband in years. In early 2026, Heroku moved to a "sustaining engineering" model and ended new Enterprise Account contracts. That's not the language of a platform investing in its future. The more telling signal came internally: Heroku's own Data Services team migrated the Essential tier off self-managed EC2 Postgres to Amazon Aurora in 2025, and is still working through the single-tenant Private Spaces migration. When a platform migrates its own infrastructure off itself, it's worth paying attention.

The technical ceiling is real and specific. Heroku Postgres doesn't support logical replication. You can't create replicas outside Heroku's own infrastructure. There's no superuser access, which blocks near-zero-downtime major version upgrades. Storage upgrades force a full plan upgrade even when CPU and RAM are nowhere near their limits. And for teams carrying HIPAA, PCI-DSS, or SOC 2 obligations, Heroku's shared runtime simply cannot provide the network isolation and audit tooling those frameworks demand. VPC placement, CloudTrail, GuardDuty, customer-managed encryption keys: none of it is available without Private Spaces, and Private Spaces add substantial cost without full configurability.

Pricing compounds the issue. The step-function from Essential to Standard-0, and from Standard-0 to anything resembling production-grade HA, is steep relative to what managed cloud databases offer at equivalent tiers. The platform made sense at a certain scale and a certain compliance posture. For many teams, that window has closed.

Choosing the Right Migration Strategy Before Touching Any Data

Two variables determine your approach before any command runs: how large is the database, and how much downtime can the application actually absorb?

Under roughly 10 GB, a native dump-and-restore with pgdump is fast, compressed efficiently, and the right tool. Heroku's own migration guides use this path. Between 10 and 100 GB, a parallel dump using pgdump -j 8 -Fd paired with pgrestore -j 8 --format=d runs the dump across multiple tables simultaneously, which reduces the maintenance window meaningfully. This approach requires enough maxconnections headroom: you need at least njobs + 1 available connections, so check your current connection ceiling before you set the -j flag.

Above 100 GB, or for any production workload that cannot tolerate a multi-hour pause, the WAL-based path is worth the additional complexity. Heroku support can provide access to WAL files on S3. A staging server replays those WAL files before the final cutover, which keeps the production gap narrow. A real-world example: a migration of a roughly 300 GB database with approximately 250 million rows used a two-phase approach in 2025, restoring a Heroku backup to an EC2 instance via wal-e, then using logical replication to sync into RDS, because RDS does not support physical streaming WAL replication directly as a replication target. That two-phase approach is the pattern to internalize for large databases.

Dry runs are not optional. Run three of them in a non-production environment before touching production. Each dry run sharpens your timing estimate, builds the step checklist, and, critically, confirms that the revert path works. Discovering on migration day that your rollback procedure has a gap is a failure of preparation.

What to Verify Before the First pg_dump Runs

Start with the schema. Review it for compatibility with the target Postgres version. Extension versions require particular attention: if the Heroku instance and the target instance are running different Postgres major versions, extension versions may not align, and that misalignment requires manual resolution before the restore will succeed.

Establish performance benchmarks now, before the migration. Measure query latency, I/O throughput, and response times on the current Heroku database. Post-migration comparison is meaningless without a baseline. "It feels faster" is not a validation.

Set the application to read-only mode, or take it offline entirely, during the dump window. Writes that occur after the dump begins are not captured. Plan conservatively: several hours is a reasonable estimate depending on database size, and the maintenance window should account for that.

Audit Heroku-specific configurations before you start. PgBouncer add-ons, follower databases, and data clips have no automatic equivalent on any managed cloud database. Each one needs a deliberate plan on the target side.

Confirm the target instance is provisioned and reachable before the dump begins. Waiting for an RDS instance to become available while holding an application in read-only mode is an avoidable mistake.

On provisioning: launch the target RDS instance at two to three times the RAM of the current Heroku Postgres plan. Do not try to right-size it on migration day. Run two weeks of baseline data on the overprovisioned instance, then use AWS Compute Optimizer, or its equivalent on your provider, to make an informed decision. Guessing on migration day costs more than the temporary over-provisioning.

Taking the Backup and Moving It to the Target Environment

The core dump command looks like this:

pg_dump postgres://DB_USERNAME:DB_PASSWORD@DB_HOST:DB_PORT/DB_NAME \
  -Fc -b -v -f /tmp/data-for-migration.sql

The -Fc flag produces the custom format. It generates smaller files than plain SQL and restores faster. Use it.

For a parallel dump into directory format:

pg_dump -j 8 -Fd -f /tmp/migration-dir

This produces a directory of files, one per table. pg_restore -j 8 can then process them in parallel on the other side.

For AWS RDS as the target, upload the backup file to an S3 bucket, block all public access on that bucket, and generate a short-lived signed URL for the restore step. A public URL for a database backup is not a shortcut. It's a liability.

Before you move to the restore step, verify that the backup file is non-zero and complete. A silently truncated dump discovered at restore time is one of the most common failure modes in database migrations. It's also entirely preventable.

Log the wall-clock duration of the dump. That number is the honest basis for your maintenance window estimate in production. The theoretical estimate is less useful than the actual measurement.

Restoring Into the Managed Database and Validating Integrity

The restore command for an RDS target:

pg_restore -h RDS_HOST -U DB_USERNAME -d DB_NAME -v /tmp/data-for-migration.sql

For the parallel directory format, replace the file path with the directory path.

Once the restore completes, validation is not a formality. For each significant table, run SELECT COUNT(*) on both source and target and compare the numbers. A mismatch stops the migration. Full stop. Do not proceed to cutover with unresolved discrepancies.

Row counts are necessary but not sufficient. Run a sample of application queries against the new database: reads that exercise joins, indexes, and foreign key relationships. This catches integrity problems that counts won't surface.

Check sequences specifically. Sequences that reset to 1 after a restore cause primary key collisions on the first insert after cutover. Confirm that every sequence is set to the correct next value before you declare the restore valid.

Verify every extension used in production is present on the target instance at the correct version. A missing or mismatched extension will surface at the worst possible moment.

If your application has a test suite with database-dependent tests, run it against the restored database. Schema mismatches that pass a row count check will fail a functional test. That is the correct order of discovery: in staging, not in production.

If validation fails at this stage, stop. Diagnose. Re-run the dump. The cost of re-running a dump is far lower than the cost of debugging a broken cutover in production.

Cutting Over With Minimal Downtime

For dump-and-restore migrations, the maintenance window approach is straightforward. Put the application in read-only or maintenance mode. Take a final incremental dump of any rows written since the initial restore, apply it to the target, then update the DATABASE_URL environment variable and restart dynos. Heroku's config var system means no code change is required if the application reads the database connection from the environment.

For WAL-based or logical replication migrations, the sequence is slightly different. Keep replication running until the lag drops to near zero, then promote the replica, update DATABASE_URL, and stop writes to the Heroku Postgres instance.

One step that cannot wait until after the migration is live: enable Multi-AZ on the RDS instance before cutover. With Multi-AZ, automatic failover recovery runs somewhere between 60 and 120 seconds. Without it, your first failure is entirely unprotected. Enabling it after launch means you've left a window open during the most volatile period.

Keep the Heroku Postgres instance running, unmodified, for at least 24 to 48 hours after cutover. It is your rollback target. Production sometimes surfaces problems that staging validation didn't catch, and having the original database intact is the difference between a quick revert and a painful reconstruction.

Monitor connection counts, query latency, and error rates immediately after the switch. Have a runbook that reverts DATABASE_URL if a defined threshold is breached. The runbook should be written before migration day, not drafted in the moment.

Where to Land: Picking the Managed Postgres Target That Matches the Workload

The single strongest predictor of a good managed Postgres choice is proximity to your compute layer. If your application runs on AWS us-east-1, your database belongs on RDS in us-east-1. Cross-provider latency is a self-inflicted penalty with no upside.

AWS RDS and Aurora are the default for teams already on AWS. Per a 2023 benchmark published by PeerDB, RDS PostgreSQL led OLTP workloads at approximately 2,700 TPS and 2.884 ms average latency. That benchmark needs updating and is indicative rather than definitive, but the directional conclusion holds: RDS is a strong OLTP target. Aurora adds automatic storage scaling and up to 15 read replicas, which matters for read-heavy workloads.

Google Cloud SQL is competitive on pricing. Per the same 2023 PeerDB benchmark, it showed lower OLTP throughput than RDS, making it a stronger fit for teams whose entire stack lives on GCP or whose workloads lean analytical rather than transactional.

Azure Database for PostgreSQL showed roughly 33% lower latency than RDS on analytical TPC-H workloads in that same benchmark. If the application is already Azure-native, the co-location advantage amplifies that performance difference further.

Neon separates storage from compute, which means compute scales to zero when idle. Following its acquisition by Databricks in May 2025, storage pricing dropped substantially, from $1.75 to $0.35 per GB-month. Cold starts are real, though, and they matter for latency-sensitive endpoints. Benchmark your specific access patterns before committing.

Supabase bundles Postgres with authentication, storage, real-time subscriptions, and Edge Functions in a single dashboard. The integration is genuinely useful for teams that want to move fast. The ceiling is equally real: there's no bring-your-own-cloud option, so teams with strict compliance or networking requirements will hit it.

Crunchy Bridge is operationally conservative managed Postgres, built by a team with deep contributions to Postgres core. It's a defensible choice when correctness and support depth matter more than feature surface area.

Railway is viable for teams that want to stay on an integrated PaaS, but its containerized Postgres lacks point-in-time recovery, read replicas, and automated failover. Experienced Railway users frequently pair it with an external managed database rather than relying on Railway Postgres for anything production-critical. Fly.io is explicit in its documentation that its Postgres offering is unmanaged and requires operator intervention if the instance crashes. Treat it the same way: pair with an external managed database for production workloads.

What Changes After the Migration: Connection Pooling, Compliance, and Ongoing Operations

Heroku PgBouncer add-ons don't follow the database. They are Heroku infrastructure, and they stay on Heroku. Any team running serverless functions, Lambda-style workloads, or high-concurrency applications needs a connection pooler configured on the target side. The two primary options are RDS Proxy, which is managed and integrates with IAM, and a self-managed PgBouncer instance. Without a pooler, connection exhaustion will surface quickly, and it will surface in production.

The compliance improvement is significant and immediate. Moving to a managed cloud database inside a customer-owned VPC makes controls available that Heroku's shared runtime simply couldn't provide: Security Groups for network isolation, CloudTrail for audit logging, GuardDuty for threat detection, and encryption at rest with customer-managed KMS keys. For HIPAA and SOC 2 workloads, these are requirements, not enhancements. They are now achievable without a workaround.

Backup posture also improves. Managed cloud databases provide configurable point-in-time recovery windows, typically up to 35 days on RDS. Before decommissioning the Heroku Postgres plan, verify the PITR retention period matches your recovery point objective. That alignment is part of the migration, not an afterthought.

The overprovisioned instance you launched for migration day should be reviewed after two weeks of production traffic. AWS Compute Optimizer and equivalent tools on GCP and Azure make right-sizing straightforward once you have real query and I/O metrics. Idle resources on a fixed plan are precisely what this migration was meant to leave behind.

Deprecate the Heroku database only after three conditions are met: 48 hours of stable production operation on the new instance, a final backup archived externally, and confirmation that no application environment still holds the old DATABASE_URL. All three. In that order.

Sources

  1. aws.amazon.com
  2. heroku.com
  3. porter.run
  4. argos-ci.com

More in PaaS Migration Guides