Move Heroku Postgres to Neon, RDS or your own server
Move Heroku Postgres to Neon, RDS or your own server
Heroku Postgres has backups. What it does not have is an exit — a way to land the same
database, intact, on a host that isn't Heroku. That is this page.
The move is a same-engine PostgreSQL transfer: schema first, data second, foreign keys
re-applied and validated at the end. Rate is $2/GB, and pre-flight prices it before
you commit anything.
Get the source URL
Do not copy DATABASE_URL out of a dashboard you opened last week:
heroku pg:credentials:url --app your-app
Heroku rotates these credentials. It rotates them on its own schedule, and it rotates
them whenever a database is failed over or upgraded. A URL that worked yesterday can be
dead today, and the failure surfaces at pre-flight as an authentication error against a
host that is plainly up — which reads like our problem and is not. Pull the URL fresh,
start the migration promptly, and if pre-flight rejects credentials you are certain of,
pull it again before doing anything else.
The URL Heroku hands you begins postgres://. Paste it as-is; both postgres:// and
postgresql:// are accepted.
Heroku requires SSL. You do not need to do anything about this — connections are made
over TLS by default. If you have appended ?sslmode=disable to a URL out of habit, remove
it, or the connection will be refused.
Pick the target endpoint carefully
This is where most Neon moves go wrong.
Neon gives you two hostnames for the same database. One contains -pooler; one does not.
Target the direct endpoint — the one without -pooler. The pooled endpoint runs in
transaction mode, which does not carry session state across statements. A migration sets
session-level parameters, creates schema, and holds a COPY open per table; through a
transaction pooler those either fail outright or fail strangely, part-way, on a large
table. Use the direct endpoint for the migration. Point your application at whichever
endpoint you prefer once the data is across.
The same rule applies to any managed Postgres that fronts itself with PgBouncer in
transaction mode. If your provider offers a "direct", "session", or "non-pooled"
connection string, that is the one to migrate through.
For RDS, the target must be reachable from the public internet, or from wherever you
run the self-hosted engine. A database in a private subnet with no route in cannot be
migrated to over a connection URL — that is not a limitation of this product so much as of
the URL itself.
What moves, and what does not
Tables, data, indexes, constraints, sequences, views, functions and custom types in the
public schema move. Foreign keys are re-applied after load, in dependency order, and
row counts and foreign-key totals are checked against the source before the migration is
allowed to report success.
Two things do not come with you:
- Roles and grants. The schema is applied without ownership or privilege statements,
because the roles a Heroku database references do not exist on the target and the apply
would abort on the first
GRANT. Everything lands owned by the user in your target URL.
Re-create whatever grants your application needs afterwards.
- Extensions Heroku installed for you. Heroku places extensions in a
heroku_ext
schema on newer stacks. If your application uses one — pgcrypto, postgis, citext,
uuid-ossp — install it on the target before starting the migration. A column typed
citext cannot be created on a database that has never heard of citext, and the schema
apply will stop there rather than guess.
Check what you are actually using:
SELECT extname FROM pg_extension WHERE extname NOT IN ('plpgsql');
Then install the same list on the target, and only then start.
Don't cut over on a guess
A migration copies a database as it was when the copy ran. Anything written to Heroku
after that point stays on Heroku.
Plan a window rather than racing the writes. The order that works:
- Put the app in maintenance mode, or scale the dynos to zero, so nothing is writing.
- Migrate, and let the verification step finish.
- Check the target yourself — count the table you care about most.
- Switch
DATABASE_URL to the new host.
- Bring the app back up.
Doing steps 2 and 3 before step 1, as a rehearsal against a throwaway target, is how you
find out what the window actually costs you before you are inside it.
Verify before you trust it
The migration verifies itself — row counts per table and foreign-key totals against the
source, failing closed on a mismatch. That is a strong check but it is not your check.
Before you point production at the new database, run the query you would run if you
suspected the worst: count the table you care about most, and open the newest row in it.
Take a backup of the new database first
The moment a database lands somewhere new is the moment it has the least protection: the
old provider's backups no longer cover it, and the new provider's may not be configured
yet. Set up an automated encrypted backup, restorable to any host, before you point traffic
at it — see Getting started.