Nov 12, 20269 min read

How to Migrate Supabase Postgres to Amazon RDS

Move the Postgres database of a Supabase project into RDS in your own AWS account: dump the public schema, decide what replaces auth.users, RLS policies and Supabase extensions, restore the data and cut over without stray writes.

Illustration for How to Migrate Supabase Postgres to Amazon RDS

A Supabase project is a Postgres database with a set of services wrapped around it, and the parts of your schema that look like plain tables often lean on those services: a profiles.id column with a foreign key into auth.users, Row Level Security policies that call auth.uid(), grants to the anon and authenticated roles, and extensions installed into a separate extensions schema. A straight pg_dump into Amazon RDS for PostgreSQL fails on exactly those lines.

This guide moves the database part only, into RDS in your own AWS account: an inventory of what depends on Supabase, the dump and the edits it needs, the restore with Encore or by hand, and a cutover that stops writes from browser clients as well as your servers.

Ways to move Supabase Postgres to RDS at a glance

Encore in your AWS accountRDS by hand
Who creates RDSEncore, on the first deployYou, with Terraform or the console
Reaching the private databaseencore db conn-uriBastion or SSM port forwarding
Schema afterwardsBootstrap migration, then files in the repoYour migration tool
Access checksAuth handler plus owner filters in queriesYour API code, or rewritten RLS policies
Local databaseencore run from the same migrationsDocker or a shared dev instance
DowntimeFinal data dump plus restoreFinal data dump plus restore

Inventory what depends on Supabase

Run these against the Supabase database before writing anything on the AWS side. Foreign keys from your tables into Supabase-managed schemas:

SELECT conrelid::regclass AS from_table, confrelid::regclass AS to_table, conname FROM pg_constraint WHERE contype = 'f' AND connamespace = 'public'::regnamespace AND confrelid::regclass::text ~ '^(auth|storage)\.';

Policies and the expressions they use:

SELECT tablename, policyname, roles, qual, with_check FROM pg_policies WHERE schemaname = 'public';

And extensions with the schema each one lives in, from SELECT extname, extnamespace::regnamespace FROM pg_extension;. The Supabase extensions guide notes that most are installed under the extensions schema, so compare the names with the RDS extension list for your target version. pgvector, pg_trgm and pgcrypto are there, while extensions that exist to serve Supabase features, such as pg_graphql and pg_net, have no counterpart, and the code that used them needs another home.

Three decisions follow from that output:

  • auth.users. Keep the user UUIDs: create a public.users table with the columns your app reads, copy the rows from auth.users, and point the foreign keys at it, so every existing owner_id stays valid whichever auth provider comes next. Password hashes and sessions belong to the auth migration, not this one.
  • RLS. Supabase RLS policies rely on auth.uid() and on PostgREST switching to the authenticated role per request. A backend on RDS usually connects as one role, so either move the owner check into the queries, as in the code below, or rewrite each policy to read a per-transaction setting such as current_setting('app.user_id', true) that your backend sets on every connection it uses.
  • Storage. Rows in storage.objects describe files that live outside Postgres. Moving them is an S3 job of its own and is left out of the dump.

How do you migrate Supabase Postgres to RDS with Encore?

Encore

Encore brings infrastructure from code to your AWS account: the database becomes a declaration in the service that uses it, and the first deploy creates an RDS for PostgreSQL instance for it, by default on PostgreSQL 18.3 with Multi-AZ, seven days of backups and deletion protection, as listed in the AWS infrastructure docs. The cleaned Supabase schema becomes the first migration, and the checks that RLS used to make move into an auth handler and the queries.

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.

Dump your own schema over the direct connection, or the session pooler on an IPv4-only network, as the Supabase connection guide recommends for pg_dump:

~/notes-app
$ mkdir -p notes/migrations$ pg_dump --schema-only --schema=public --no-owner --no-acl \ "$SUPABASE_DB_URL" > notes/migrations/1_bootstrap.up.sql

Edit the file: delete CREATE SCHEMA public; if pg_dump emitted it, CREATE POLICY and ALTER TABLE ... ENABLE ROW LEVEL SECURITY lines, drop triggers on auth.users such as a handle_new_user function, replace extensions. prefixes with the schema you will install them in, and repoint foreign keys from auth.users to the new users table. For a small notes app the result looks like this:

-- From pg_dump --schema=public, with Supabase-only parts removed: -- RLS policies on auth.uid(), grants to anon/authenticated, and the -- foreign key into auth.users, which now points at public.users. CREATE TABLE users ( id uuid PRIMARY KEY, -- same IDs as auth.users email text UNIQUE, -- null for phone-only sign-ups created_at timestamptz NOT NULL DEFAULT now() ); CREATE TABLE notes ( id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, owner_id uuid NOT NULL REFERENCES users (id) ON DELETE CASCADE, body text NOT NULL, created_at timestamptz NOT NULL DEFAULT now() ); CREATE INDEX notes_owner_id_idx ON notes (owner_id);

The service declares the database and runs every query with the caller's ID from the auth handler:

// notes/notes.go package notes import ( "context" "time" "encore.dev/beta/auth" "encore.dev/beta/errs" "encore.dev/storage/sqldb" ) // RDS for PostgreSQL in your AWS account, a local Postgres with `encore run`. var db = sqldb.NewDatabase("notes", sqldb.DatabaseConfig{ Migrations: "./migrations", }) // AuthHandler replaces the JWT check Supabase did in front of PostgREST. // //encore:authhandler func AuthHandler(ctx context.Context, token string) (auth.UID, error) { return verifySession(ctx, token) } func verifySession(ctx context.Context, token string) (auth.UID, error) { // Verify the token with the auth provider you moved to and return // the user's ID, which is the same UUID the user had in auth.users. return "", &errs.Error{Code: errs.Unauthenticated, Message: "invalid session"} } type Note struct { ID int64 `json:"id"` Body string `json:"body"` CreatedAt time.Time `json:"created_at"` } type ListResponse struct { Notes []Note `json:"notes"` } // ListNotes returns the caller's notes. The owner check that used to live // in an RLS policy on auth.uid() is now part of the query. // //encore:api auth method=GET path=/notes func ListNotes(ctx context.Context) (*ListResponse, error) { uid, _ := auth.UserID() rows, err := db.Query(ctx, ` SELECT id, body, created_at FROM notes WHERE owner_id = $1 ORDER BY created_at DESC LIMIT 100`, string(uid)) if err != nil { return nil, err } defer rows.Close() resp := &ListResponse{Notes: []Note{}} for rows.Next() { var n Note if err := rows.Scan(&n.ID, &n.Body, &n.CreatedAt); err != nil { return nil, err } resp.Notes = append(resp.Notes, n) } return resp, rows.Err() } type CreateParams struct { Body string `json:"body"` } //encore:api auth method=POST path=/notes func CreateNote(ctx context.Context, p *CreateParams) (*Note, error) { if p.Body == "" || len(p.Body) > 10_000 { return nil, &errs.Error{Code: errs.InvalidArgument, Message: "body must be 1 to 10000 characters"} } uid, _ := auth.UserID() n := Note{Body: p.Body} err := db.QueryRow(ctx, ` INSERT INTO notes (owner_id, body) VALUES ($1, $2) RETURNING id, created_at`, string(uid), p.Body, ).Scan(&n.ID, &n.CreatedAt) return &n, err }

encore run starts Postgres locally and applies the bootstrap migration, which is the quickest way to find a leftover auth. reference before anything reaches AWS. The owner filter now sits in Go, where tests and code review can see it, instead of in a policy that only applies when the request arrives through the right role. A coding agent extending this service adds a table as 2_add_tags.up.sql and runs it against the local database, and the next deploy applies the same file to RDS.

Deploy and load the data

Connect your AWS account as described in Connect your cloud account, create an environment on AWS and push, so Encore creates RDS in a private subnet and applies the schema. Then load users first, since notes references them, and the rest of the data after, through the temporary connection string from encore db conn-uri covered in the database docs:

~/notes-app
$ git push encore$ psql "$SUPABASE_DB_URL" -c "\copy (SELECT id, email, created_at FROM auth.users) TO users.csv CSV"$ pg_dump --data-only --schema=public --format=custom -f data.dump "$SUPABASE_DB_URL"$ export RDS_URL="$(encore db conn-uri notes --env=prod --admin)"$ psql "$RDS_URL" -c "\copy users (id, email, created_at) FROM users.csv CSV"$ pg_restore --data-only --no-owner --no-acl --single-transaction -d "$RDS_URL" data.dump

If the Supabase schema already has its own public.users table, skip the CSV step and let pg_restore load it with the rest.

Best fit: teams taking the Postgres part of a Supabase project into their own AWS account who want RDS created from the backend code, access checks they can test, and the same schema in every new environment.

RDS by hand

Create the RDS instance, a DB subnet group in private subnets and a security group, then reach it from your machine through a bastion with Session Manager port forwarding, since a private instance has no public endpoint:

~
$ aws ssm start-session --target $BASTION_ID \ --document-name AWS-StartPortForwardingSessionToRemoteHost \ --parameters '{"host":["'$RDS_HOST'"],"portNumber":["5432"],"localPortNumber":["5433"]}'$ psql "host=127.0.0.1 port=5433 user=postgres dbname=notes" -f schema-cleaned.sql$ psql "host=127.0.0.1 port=5433 user=postgres dbname=notes" -c "\copy users FROM users.csv CSV"$ pg_restore --data-only --no-owner --no-acl -h 127.0.0.1 -p 5433 -U postgres -d notes data.dump

With the same schema edits as above, what remains is yours to build: a non-master role for the app with only the grants it needs, the connection string in Secrets Manager for the service to read, a parameter group if you rely on non-default settings, and a way to apply the next migration to every environment.

Best fit: teams with an existing RDS layout in Terraform, or databases that keep RLS and need roles and policies designed by hand on the new server.

The cutover

Supabase clients can write to the database straight from the browser through the API, so stopping your servers is not enough to freeze writes. During the window, run REVOKE INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public FROM anon, authenticated; on Supabase, the roles PostgREST uses for signed-out and signed-in requests, and stop any backend that writes with the service role key. Then take the final data dump, restore it, run ANALYZE; on RDS, compare count(*) and max(created_at) for the main tables on both sides, and release the new app. Keep the Supabase project running unchanged for a few days so rolling back only means restoring those grants.

How to choose

Pick Encore when the backend is moving to your own AWS account and you want RDS, its network and the schema history created from the service code, with the Supabase-specific pieces replaced by code you can run locally. Pick RDS by hand when an infrastructure team owns the database layout, or when you plan to keep RLS and design the roles yourself.

Frequently asked questions

How do I migrate a Supabase database to Amazon RDS?

Dump your own schema with pg_dump --schema=public --no-owner --no-acl over the direct connection or the session pooler, remove the RLS policies, grants to anon and authenticated, and foreign keys into auth.users, and restore it on RDS. Then copy the user IDs you need out of auth.users, restore the data with pg_restore --data-only, and verify counts before switching the app.

What happens to Supabase RLS policies on RDS?

Policies that call auth.uid() or target the anon and authenticated roles stop making sense, since those functions and roles only exist in Supabase. Either move the ownership checks into the queries your backend runs, or rewrite the policies to read a setting your backend sets per transaction, and test both with a non-owner database role.

How do I move Supabase Postgres to RDS with Encore?

Commit the cleaned public schema as migrations/1_bootstrap.up.sql, declare the database with sqldb.NewDatabase, and move auth checks into an Encore auth handler and the queries themselves. Deploy to an AWS environment so Encore creates RDS and applies the schema, then load users and data into the URI from encore db conn-uri notes --env=prod --admin.

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