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.

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.
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.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.objects describe files that live outside Postgres. Moving them is an S3 job of its own and is left out of the dump.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.

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:
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.
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:
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.
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:
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.
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.
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.
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.
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.
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.